blob: b4c3d194915282c3613c2ffb1c1dcf58f60770a8 [file] [log] [blame]
Craig Tillerc2c79212015-02-16 12:00:01 -08001# Copyright 2015, Google Inc.
2# All rights reserved.
3#
4# Redistribution and use in source and binary forms, with or without
5# modification, are permitted provided that the following conditions are
6# met:
7#
8# * Redistributions of source code must retain the above copyright
9# notice, this list of conditions and the following disclaimer.
10# * Redistributions in binary form must reproduce the above
11# copyright notice, this list of conditions and the following disclaimer
12# in the documentation and/or other materials provided with the
13# distribution.
14# * Neither the name of Google Inc. nor the names of its
15# contributors may be used to endorse or promote products derived from
16# this software without specific prior written permission.
17#
18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
Nicolas Nobleddef2462015-01-06 18:08:25 -080030"""Run a group of subprocesses and then finish."""
31
Craig Tiller71735182015-01-15 17:07:13 -080032import hashlib
Nicolas Nobleddef2462015-01-06 18:08:25 -080033import multiprocessing
Craig Tiller71735182015-01-15 17:07:13 -080034import os
Craig Tiller5058c692015-04-08 09:42:04 -070035import platform
Nicolas Nobleddef2462015-01-06 18:08:25 -080036import random
Craig Tiller336ad502015-02-24 14:46:02 -080037import signal
Nicolas Nobleddef2462015-01-06 18:08:25 -080038import subprocess
39import sys
ctiller3040cb72015-01-07 12:13:17 -080040import tempfile
41import time
Nicolas Nobleddef2462015-01-06 18:08:25 -080042
ctiller3040cb72015-01-07 12:13:17 -080043
ctiller94e5dde2015-01-09 10:41:59 -080044_DEFAULT_MAX_JOBS = 16 * multiprocessing.cpu_count()
Nicolas Nobleddef2462015-01-06 18:08:25 -080045
46
Craig Tiller336ad502015-02-24 14:46:02 -080047# setup a signal handler so that signal.pause registers 'something'
48# when a child finishes
49# not using futures and threading to avoid a dependency on subprocess32
Craig Tiller5058c692015-04-08 09:42:04 -070050if platform.system() == "Windows":
51 pass
52else:
53 have_alarm = False
54 def alarm_handler(unused_signum, unused_frame):
55 global have_alarm
56 have_alarm = False
57
58 signal.signal(signal.SIGCHLD, lambda unused_signum, unused_frame: None)
59 signal.signal(signal.SIGALRM, alarm_handler)
Craig Tiller336ad502015-02-24 14:46:02 -080060
61
Nicolas Nobleddef2462015-01-06 18:08:25 -080062def shuffle_iteratable(it):
63 """Return an iterable that randomly walks it"""
64 # take a random sampling from the passed in iterable
65 # we take an element with probablity 1/p and rapidly increase
66 # p as we take elements - this gives us a somewhat random set of values before
67 # we've seen all the values, but starts producing values without having to
68 # compute ALL of them at once, allowing tests to start a little earlier
69 nextit = []
70 p = 1
71 for val in it:
72 if random.randint(0, p) == 0:
ctiller3040cb72015-01-07 12:13:17 -080073 p = min(p*2, 100)
Nicolas Nobleddef2462015-01-06 18:08:25 -080074 yield val
75 else:
76 nextit.append(val)
77 # after taking a random sampling, we shuffle the rest of the elements and
78 # yield them
79 random.shuffle(nextit)
80 for val in nextit:
81 yield val
82
83
ctiller3040cb72015-01-07 12:13:17 -080084_SUCCESS = object()
85_FAILURE = object()
86_RUNNING = object()
87_KILLED = object()
88
89
Craig Tiller3b083062015-01-12 13:51:28 -080090_COLORS = {
Nicolas Noble044db742015-01-14 16:57:24 -080091 'red': [ 31, 0 ],
92 'green': [ 32, 0 ],
93 'yellow': [ 33, 0 ],
94 'lightgray': [ 37, 0],
95 'gray': [ 30, 1 ],
Craig Tiller3b083062015-01-12 13:51:28 -080096 }
97
98
99_BEGINNING_OF_LINE = '\x1b[0G'
100_CLEAR_LINE = '\x1b[2K'
101
102
103_TAG_COLOR = {
104 'FAILED': 'red',
Craig Tillere1d0d1c2015-02-27 08:54:23 -0800105 'TIMEOUT': 'red',
Craig Tiller3b083062015-01-12 13:51:28 -0800106 'PASSED': 'green',
Nicolas Noble044db742015-01-14 16:57:24 -0800107 'START': 'gray',
Craig Tiller3b083062015-01-12 13:51:28 -0800108 'WAITING': 'yellow',
Nicolas Noble044db742015-01-14 16:57:24 -0800109 'SUCCESS': 'green',
110 'IDLE': 'gray',
Craig Tiller3b083062015-01-12 13:51:28 -0800111 }
112
113
Nicolas Noble044db742015-01-14 16:57:24 -0800114def message(tag, message, explanatory_text=None, do_newline=False):
Craig Tiller5058c692015-04-08 09:42:04 -0700115 if platform.system() == 'Windows':
116 if explanatory_text:
117 print explanatory_text
118 print '%s: %s' % (tag, message)
119 return
Craig Tiller23d2f3f2015-02-24 15:23:32 -0800120 try:
121 sys.stdout.write('%s%s%s\x1b[%d;%dm%s\x1b[0m: %s%s' % (
122 _BEGINNING_OF_LINE,
123 _CLEAR_LINE,
124 '\n%s' % explanatory_text if explanatory_text is not None else '',
125 _COLORS[_TAG_COLOR[tag]][1],
126 _COLORS[_TAG_COLOR[tag]][0],
127 tag,
128 message,
129 '\n' if do_newline or explanatory_text is not None else ''))
130 sys.stdout.flush()
131 except:
132 pass
Craig Tiller3b083062015-01-12 13:51:28 -0800133
134
Craig Tiller71735182015-01-15 17:07:13 -0800135def which(filename):
136 if '/' in filename:
137 return filename
138 for path in os.environ['PATH'].split(os.pathsep):
139 if os.path.exists(os.path.join(path, filename)):
140 return os.path.join(path, filename)
141 raise Exception('%s not found' % filename)
142
143
Craig Tiller547db2b2015-01-30 14:08:39 -0800144class JobSpec(object):
145 """Specifies what to run for a job."""
146
Craig Tiller5058c692015-04-08 09:42:04 -0700147 def __init__(self, cmdline, shortname=None, environ=None, hash_targets=None, cwd=None):
Craig Tiller547db2b2015-01-30 14:08:39 -0800148 """
149 Arguments:
150 cmdline: a list of arguments to pass as the command line
151 environ: a dictionary of environment variables to set in the child process
152 hash_targets: which files to include in the hash representing the jobs version
153 (or empty, indicating the job should not be hashed)
154 """
murgatroid99132ce6a2015-03-04 17:29:14 -0800155 if environ is None:
156 environ = {}
157 if hash_targets is None:
158 hash_targets = []
Craig Tiller547db2b2015-01-30 14:08:39 -0800159 self.cmdline = cmdline
160 self.environ = environ
161 self.shortname = cmdline[0] if shortname is None else shortname
162 self.hash_targets = hash_targets or []
Craig Tiller5058c692015-04-08 09:42:04 -0700163 self.cwd = cwd
Craig Tiller547db2b2015-01-30 14:08:39 -0800164
165 def identity(self):
166 return '%r %r %r' % (self.cmdline, self.environ, self.hash_targets)
167
168 def __hash__(self):
169 return hash(self.identity())
170
171 def __cmp__(self, other):
172 return self.identity() == other.identity()
173
174
ctiller3040cb72015-01-07 12:13:17 -0800175class Job(object):
176 """Manages one job."""
177
Nicolas "Pixel" Noblea7df3f92015-02-26 22:07:04 +0100178 def __init__(self, spec, bin_hash, newline_on_success, travis):
Craig Tiller547db2b2015-01-30 14:08:39 -0800179 self._spec = spec
Craig Tiller71735182015-01-15 17:07:13 -0800180 self._bin_hash = bin_hash
ctiller3040cb72015-01-07 12:13:17 -0800181 self._tempfile = tempfile.TemporaryFile()
Craig Tiller547db2b2015-01-30 14:08:39 -0800182 env = os.environ.copy()
183 for k, v in spec.environ.iteritems():
184 env[k] = v
Craig Tiller9d6139a2015-02-26 15:24:43 -0800185 self._start = time.time()
Craig Tiller5058c692015-04-08 09:42:04 -0700186 print spec.cwd
Craig Tiller547db2b2015-01-30 14:08:39 -0800187 self._process = subprocess.Popen(args=spec.cmdline,
ctiller3040cb72015-01-07 12:13:17 -0800188 stderr=subprocess.STDOUT,
Craig Tiller547db2b2015-01-30 14:08:39 -0800189 stdout=self._tempfile,
Craig Tiller5058c692015-04-08 09:42:04 -0700190 cwd=spec.cwd,
Craig Tiller547db2b2015-01-30 14:08:39 -0800191 env=env)
ctiller3040cb72015-01-07 12:13:17 -0800192 self._state = _RUNNING
Nicolas Noble044db742015-01-14 16:57:24 -0800193 self._newline_on_success = newline_on_success
Nicolas "Pixel" Noblea7df3f92015-02-26 22:07:04 +0100194 self._travis = travis
Craig Tillerb84728d2015-02-26 15:40:39 -0800195 message('START', spec.shortname, do_newline=self._travis)
ctiller3040cb72015-01-07 12:13:17 -0800196
Craig Tiller71735182015-01-15 17:07:13 -0800197 def state(self, update_cache):
ctiller3040cb72015-01-07 12:13:17 -0800198 """Poll current state of the job. Prints messages at completion."""
199 if self._state == _RUNNING and self._process.poll() is not None:
Craig Tiller9d6139a2015-02-26 15:24:43 -0800200 elapsed = time.time() - self._start
ctiller3040cb72015-01-07 12:13:17 -0800201 if self._process.returncode != 0:
202 self._state = _FAILURE
203 self._tempfile.seek(0)
204 stdout = self._tempfile.read()
Craig Tiller71735182015-01-15 17:07:13 -0800205 message('FAILED', '%s [ret=%d]' % (
David Klempner559543e2015-03-17 20:27:48 -0700206 self._spec.shortname, self._process.returncode), stdout, do_newline=True)
ctiller3040cb72015-01-07 12:13:17 -0800207 else:
208 self._state = _SUCCESS
Craig Tiller9d6139a2015-02-26 15:24:43 -0800209 message('PASSED', '%s [time=%.1fsec]' % (self._spec.shortname, elapsed),
Nicolas "Pixel" Noblea7df3f92015-02-26 22:07:04 +0100210 do_newline=self._newline_on_success or self._travis)
Craig Tiller547db2b2015-01-30 14:08:39 -0800211 if self._bin_hash:
212 update_cache.finished(self._spec.identity(), self._bin_hash)
Craig Tiller9b3cc742015-02-26 22:25:03 -0800213 elif self._state == _RUNNING and time.time() - self._start > 300:
David Klempner559543e2015-03-17 20:27:48 -0700214 message('TIMEOUT', self._spec.shortname, do_newline=True)
Craig Tiller9b3cc742015-02-26 22:25:03 -0800215 self.kill()
ctiller3040cb72015-01-07 12:13:17 -0800216 return self._state
217
218 def kill(self):
219 if self._state == _RUNNING:
220 self._state = _KILLED
221 self._process.terminate()
222
223
Nicolas Nobleddef2462015-01-06 18:08:25 -0800224class Jobset(object):
225 """Manages one run of jobs."""
226
Nicolas "Pixel" Noblea7df3f92015-02-26 22:07:04 +0100227 def __init__(self, check_cancelled, maxjobs, newline_on_success, travis, cache):
ctiller3040cb72015-01-07 12:13:17 -0800228 self._running = set()
229 self._check_cancelled = check_cancelled
230 self._cancelled = False
Nicolas Nobleddef2462015-01-06 18:08:25 -0800231 self._failures = 0
Craig Tiller738c3342015-01-12 14:28:33 -0800232 self._completed = 0
ctiller94e5dde2015-01-09 10:41:59 -0800233 self._maxjobs = maxjobs
Nicolas Noble044db742015-01-14 16:57:24 -0800234 self._newline_on_success = newline_on_success
Nicolas "Pixel" Noblea7df3f92015-02-26 22:07:04 +0100235 self._travis = travis
Craig Tiller71735182015-01-15 17:07:13 -0800236 self._cache = cache
Nicolas Nobleddef2462015-01-06 18:08:25 -0800237
Craig Tiller547db2b2015-01-30 14:08:39 -0800238 def start(self, spec):
ctiller3040cb72015-01-07 12:13:17 -0800239 """Start a job. Return True on success, False on failure."""
ctiller94e5dde2015-01-09 10:41:59 -0800240 while len(self._running) >= self._maxjobs:
ctiller3040cb72015-01-07 12:13:17 -0800241 if self.cancelled(): return False
242 self.reap()
243 if self.cancelled(): return False
Craig Tiller547db2b2015-01-30 14:08:39 -0800244 if spec.hash_targets:
245 bin_hash = hashlib.sha1()
246 for fn in spec.hash_targets:
247 with open(which(fn)) as f:
248 bin_hash.update(f.read())
249 bin_hash = bin_hash.hexdigest()
250 should_run = self._cache.should_run(spec.identity(), bin_hash)
251 else:
252 bin_hash = None
253 should_run = True
254 if should_run:
Craig Tiller5058c692015-04-08 09:42:04 -0700255 try:
256 self._running.add(Job(spec,
257 bin_hash,
258 self._newline_on_success,
259 self._travis))
260 except:
261 message('FAILED', spec.shortname)
262 self._cancelled = True
263 return False
ctiller3040cb72015-01-07 12:13:17 -0800264 return True
Nicolas Nobleddef2462015-01-06 18:08:25 -0800265
ctiller3040cb72015-01-07 12:13:17 -0800266 def reap(self):
267 """Collect the dead jobs."""
268 while self._running:
269 dead = set()
270 for job in self._running:
Craig Tiller71735182015-01-15 17:07:13 -0800271 st = job.state(self._cache)
ctiller3040cb72015-01-07 12:13:17 -0800272 if st == _RUNNING: continue
273 if st == _FAILURE: self._failures += 1
Craig Tiller9b3cc742015-02-26 22:25:03 -0800274 if st == _KILLED: self._failures += 1
ctiller3040cb72015-01-07 12:13:17 -0800275 dead.add(job)
276 for job in dead:
Craig Tiller738c3342015-01-12 14:28:33 -0800277 self._completed += 1
ctiller3040cb72015-01-07 12:13:17 -0800278 self._running.remove(job)
Craig Tiller3b083062015-01-12 13:51:28 -0800279 if dead: return
Nicolas "Pixel" Noblea7df3f92015-02-26 22:07:04 +0100280 if (not self._travis):
281 message('WAITING', '%d jobs running, %d complete, %d failed' % (
282 len(self._running), self._completed, self._failures))
Craig Tiller5058c692015-04-08 09:42:04 -0700283 if platform.system() == 'Windows':
284 time.sleep(0.1)
285 else:
286 global have_alarm
287 if not have_alarm:
288 have_alarm = True
289 signal.alarm(10)
290 signal.pause()
ctiller3040cb72015-01-07 12:13:17 -0800291
292 def cancelled(self):
293 """Poll for cancellation."""
294 if self._cancelled: return True
295 if not self._check_cancelled(): return False
296 for job in self._running:
297 job.kill()
298 self._cancelled = True
299 return True
300
301 def finish(self):
302 while self._running:
303 if self.cancelled(): pass # poll cancellation
304 self.reap()
305 return not self.cancelled() and self._failures == 0
Nicolas Nobleddef2462015-01-06 18:08:25 -0800306
307
ctiller3040cb72015-01-07 12:13:17 -0800308def _never_cancelled():
309 return False
310
311
Craig Tiller71735182015-01-15 17:07:13 -0800312# cache class that caches nothing
313class NoCache(object):
314 def should_run(self, cmdline, bin_hash):
315 return True
316
317 def finished(self, cmdline, bin_hash):
318 pass
319
320
Nicolas Nobleb09078f2015-01-14 18:06:05 -0800321def run(cmdlines,
322 check_cancelled=_never_cancelled,
323 maxjobs=None,
Craig Tiller71735182015-01-15 17:07:13 -0800324 newline_on_success=False,
Nicolas "Pixel" Noblea7df3f92015-02-26 22:07:04 +0100325 travis=False,
Craig Tiller71735182015-01-15 17:07:13 -0800326 cache=None):
ctiller94e5dde2015-01-09 10:41:59 -0800327 js = Jobset(check_cancelled,
Nicolas Noble044db742015-01-14 16:57:24 -0800328 maxjobs if maxjobs is not None else _DEFAULT_MAX_JOBS,
Nicolas "Pixel" Noblea7df3f92015-02-26 22:07:04 +0100329 newline_on_success, travis,
Craig Tiller71735182015-01-15 17:07:13 -0800330 cache if cache is not None else NoCache())
Craig Tillerb84728d2015-02-26 15:40:39 -0800331 if not travis:
332 cmdlines = shuffle_iteratable(cmdlines)
333 else:
Craig Tiller904da8c2015-02-26 15:59:15 -0800334 cmdlines = sorted(cmdlines, key=lambda x: x.shortname)
Craig Tillerb84728d2015-02-26 15:40:39 -0800335 for cmdline in cmdlines:
ctiller3040cb72015-01-07 12:13:17 -0800336 if not js.start(cmdline):
337 break
338 return js.finish()