| #!/usr/bin/env python3 | |
| from datetime import datetime, timedelta | |
| import argparse | |
| import collections | |
| import io | |
| import os | |
| import os.path | |
| import re | |
| import resource | |
| import signal | |
| import subprocess | |
| import sys | |
| import traceback | |
| import junit_xml | |
| secs_in_min = 60 | |
| secs_in_hour = 60 * secs_in_min | |
| def pretty_timedelta_str(d): | |
| """Pretty print a time delta object. | |
| >>> pretty_timedelta_str(timedelta(seconds=2)) | |
| '2s' | |
| >>> pretty_timedelta_str(timedelta(seconds=61)) | |
| '1m01s' | |
| >>> pretty_timedelta_str(timedelta(seconds=125)) | |
| '2m05s' | |
| >>> pretty_timedelta_str(timedelta(seconds=secs_in_hour * 5)) | |
| '5h00m' | |
| Once we get to displaying hours, we don't bother displaying seconds. | |
| >>> pretty_timedelta_str(timedelta(seconds=secs_in_hour * 5 + 1)) | |
| '5h00m' | |
| >>> pretty_timedelta_str(timedelta( | |
| ... seconds=secs_in_hour * 5 + secs_in_min * 2 + 1)) | |
| '5h02m' | |
| >>> pretty_timedelta_str(timedelta(seconds=secs_in_hour * 5.5)) | |
| '5h30m' | |
| """ | |
| assert isinstance(d, timedelta) | |
| ts = d.total_seconds() | |
| assert ts > 0, (d, ts) | |
| bits = [] | |
| hours = int(ts / secs_in_hour) | |
| if hours > 0: | |
| bits.append("{}h".format(hours)) | |
| ts -= hours * secs_in_hour | |
| mins = int(ts / secs_in_min) | |
| if mins > 0 or hours > 0: | |
| if hours > 0: | |
| bits.append("{:02d}m".format(mins)) | |
| else: | |
| bits.append("{:d}m".format(mins)) | |
| ts -= mins * secs_in_min | |
| if hours == 0: | |
| if mins > 0: | |
| bits.append("{:02d}s".format(int(ts))) | |
| else: | |
| bits.append("{:d}s".format(int(ts))) | |
| assert len(bits) > 0, d | |
| return "".join(bits) | |
| class Logger: | |
| """Output a bunch of lines with a prefix. | |
| >>> l = Logger("fuzz", datetime(2001, 11, 1), 6) | |
| >>> l._now = lambda: datetime(2001, 11, 1, second=10) | |
| >>> l.log("Test!") | |
| 2001-11-01T00:00:10 - fuzz - 10s: Test! | |
| >>> l.log("Format {} {t}", [1,], {'t': 2}) | |
| 2001-11-01T00:00:10 - fuzz - 10s: Format 1 2 | |
| >>> l.log('''\\ | |
| ... Line 1 | |
| ... Line 2 | |
| ... Line 3 | |
| ... ''') | |
| 2001-11-01T00:00:10 - fuzz - 10s: Line 1 | |
| 2001-11-01T00:00:10 - fuzz - 10s: Line 2 | |
| 2001-11-01T00:00:10 - fuzz - 10s: Line 3 | |
| """ | |
| def __init__(self, fuzzer, time_start, padding): | |
| self.fuzzer = fuzzer + ' ' * (padding - len(fuzzer)) | |
| self.time_start = time_start | |
| self.queue = collections.deque() | |
| def _now(self): | |
| return datetime.utcnow() | |
| def log(self, msg, args=None, kw=None, flush=True): | |
| """Queue a log message. | |
| This is safe to do in a signal handler where you can't do io. | |
| """ | |
| if args is None: | |
| args = [] | |
| if kw is None: | |
| kw = {} | |
| time_log = self._now() | |
| self.queue.append((time_log, msg, args, kw)) | |
| if flush: | |
| self.flush() | |
| def flush(self): | |
| while len(self.queue) > 0: | |
| time_log, msg, args, kw = self.queue.popleft() | |
| self._output(time_log, msg, args, kw) | |
| def _output(self, time_log, msg, args, kw): | |
| running_for = time_log - self.time_start | |
| msg = msg.format(*args, **kw) | |
| log_prefix = "{:s} - {:s} - {:>5s}: ".format( | |
| time_log.isoformat(), | |
| self.fuzzer, | |
| pretty_timedelta_str(running_for), | |
| ) | |
| msg = "\n".join(log_prefix + x for x in msg.splitlines()) | |
| print(msg, flush=True) | |
| def last_lines(f, num, blocksize=100): | |
| r"""Return n last lines in a file. | |
| >>> f = io.StringIO("") | |
| >>> last_lines(f, 100) | |
| [''] | |
| >>> f = io.StringIO("1\n2") | |
| >>> last_lines(f, 100) | |
| ['1', '2'] | |
| >>> f = io.StringIO("11\n22\n33\n44\n55") | |
| >>> last_lines(f, 100) | |
| ['11', '22', '33', '44', '55'] | |
| >>> f = io.StringIO("11\n22\n33\n44\n55") | |
| >>> last_lines(f, 100, 2) | |
| ['11', '22', '33', '44', '55'] | |
| >>> f = io.StringIO("11\n22\n33\n44\n55") | |
| >>> last_lines(f, 100, 3) | |
| ['11', '22', '33', '44', '55'] | |
| >>> f = io.StringIO("11\n22\n33\n44\n55") | |
| >>> last_lines(f, 2, 2) | |
| ['44', '55'] | |
| >>> f = io.StringIO("11\n22\n33\n44\n55") | |
| >>> last_lines(f, 2, 3) | |
| ['44', '55'] | |
| >>> f = io.StringIO("11\n22\n33\n44\n55") | |
| >>> last_lines(f, 2, 4) | |
| ['44', '55'] | |
| >>> f = io.StringIO("1\n22\n3333333\n") | |
| >>> last_lines(f, 100, 3) | |
| ['1', '22', '3333333', ''] | |
| """ | |
| lines = collections.deque(['']) | |
| # Seek to the end of the file | |
| f.seek(0, os.SEEK_END) | |
| fpos = f.tell() | |
| while len(lines) < (num + 1) and fpos > 0: | |
| bs = min(blocksize, fpos) | |
| fpos = fpos - bs | |
| f.seek(fpos) | |
| data = f.read(bs) | |
| while True: | |
| lpos = data.rfind('\n') | |
| lines[0] = data[lpos + 1:] + lines[0] | |
| if lpos == -1: | |
| break | |
| lines.insert(0, '') | |
| data = data[:lpos] | |
| if fpos == 0: | |
| lines.insert(0, '') | |
| return list(lines)[1:] | |
| def get_usage(): | |
| # This function only works if you have a signal handler for the | |
| # signal.SIGCHLD signal. | |
| raw_usage = resource.getrusage(resource.RUSAGE_CHILDREN) | |
| # 0 ru_utime time in user mode (float) | |
| # 1 ru_stime time in system mode (float) | |
| # 2 ru_maxrss maximum resident set size | |
| # | |
| # These fields are always zero on Linux | |
| # 3 ru_ixrss shared memory size | |
| # 4 ru_idrss unshared memory size | |
| # 5 ru_isrss unshared stack size | |
| return "User:{}s System:{}s".format( | |
| int(raw_usage.ru_utime), | |
| int(raw_usage.ru_stime), | |
| ) | |
| def get_load(): | |
| """Return current load average. | |
| Values is average perecntage of CPU used over 1 minute, 5 minutes, 15 | |
| minutes. | |
| """ | |
| a1, a5, a15 = os.getloadavg() | |
| cpus = os.cpu_count() | |
| return a1 / cpus * 100.0, a5 / cpus * 100.0, a15 / cpus * 100.0 | |
| class PsTree: | |
| _pstree_features = None | |
| @classmethod | |
| def get_features(cls): | |
| if cls._pstree_features is None: | |
| cls._pstree_features = [] | |
| for f in ['-T', '-l']: | |
| try: | |
| subprocess.check_output( | |
| "pstree {}".format(f), | |
| stderr=subprocess.STDOUT, | |
| shell=True, | |
| ) | |
| cls._pstree_features.append(f) | |
| except subprocess.CalledProcessError: | |
| continue | |
| return cls._pstree_features | |
| @classmethod | |
| def get(cls, pid): | |
| """Get processes under current one. | |
| Requires the pstree until be installed, otherwise returns empty string. | |
| """ | |
| p = subprocess.Popen( | |
| "pstree {} {}".format(" ".join(cls.get_features()), pid), | |
| shell=True, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| stdin=subprocess.DEVNULL, | |
| universal_newlines=True, | |
| ) | |
| stdout, stderr = p.communicate() | |
| # Special case segfaults. | |
| if 'Segmentation fault' in stderr: | |
| stderr = '' | |
| stdout = '(result too big, pstree segfaulted.)' | |
| # If no error, just return stdout | |
| if not stderr: | |
| return stdout | |
| return "{}\n{}".format(stdout, stderr) | |
| def mem_convert(s): | |
| """ | |
| >>> mem_convert('62G') | |
| 62000000000.0 | |
| >>> mem_convert('62M') | |
| 62000000.0 | |
| >>> mem_convert('62B') | |
| 62.0 | |
| """ | |
| units = { | |
| 'Gi': pow(2, 30), | |
| 'Mi': pow(2, 20), | |
| 'Ki': pow(2, 10), | |
| 'G': 1e9, | |
| 'M': 1e6, | |
| 'K': 1e3, | |
| 'B': 1, | |
| } | |
| u = '', | |
| m = 1 | |
| for u, m in units.items(): | |
| if s.endswith(u): | |
| break | |
| v = float(s[:-len(u)]) | |
| v = v * m | |
| return v | |
| def get_memory(memstr=None): | |
| r""" | |
| >>> import pprint | |
| >>> pprint.pprint(get_memory('''\ | |
| ... total used free shared buff/cache available | |
| ... Mem: 62G 19G 4.8G 661M 38G 42G | |
| ... Swap: 0B 0B 0B | |
| ... ''')) | |
| {'mem': {'available': 42000000000.0, | |
| 'buff/cache': 38000000000.0, | |
| 'free': 4800000000.0, | |
| 'shared': 661000000.0, | |
| 'total': 62000000000.0, | |
| 'used': 19000000000.0}, | |
| 'swap': {'free': 0.0, 'total': 0.0, 'used': 0.0}} | |
| """ | |
| if memstr is None: | |
| memstr = subprocess.check_output( | |
| 'free -mh', shell=True).decode("utf-8") | |
| lines = [x.split() for x in memstr.strip().splitlines()] | |
| lines[0].insert(0, 'type:') | |
| for l in lines: | |
| l[0] = l[0][:-1].lower() | |
| headers = lines[0][1:] | |
| lines = lines[1:] | |
| memory = {} | |
| for l in lines: | |
| t, l = l[0], l[1:] | |
| d = {} | |
| for k, v in zip(headers, l): | |
| d[k] = mem_convert(v) | |
| memory[t] = d | |
| return memory | |
| def should_run_submake(make_flags): | |
| """Check if make_flags indicate that we should execute things. | |
| See https://www.gnu.org/software/make/manual/html_node/Instead-of-Execution.html#Instead-of-Execution # noqa | |
| If this is a dry run or question then we shouldn't execute or output | |
| anything. | |
| The flags end up as single letter versions in the MAKEFLAGS environment | |
| variable. | |
| >>> should_run_submake('') | |
| True | |
| The following flags are important; | |
| -n == --dry-run | |
| >>> should_run_submake('n') | |
| False | |
| >>> should_run_submake('n --blah') | |
| False | |
| >>> should_run_submake('--blah n') | |
| False | |
| >>> should_run_submake('--blah') | |
| True | |
| >>> should_run_submake('--random') | |
| True | |
| -q == --question | |
| >>> should_run_submake('q') | |
| False | |
| >>> should_run_submake('q --blah') | |
| False | |
| >>> should_run_submake('--blah q') | |
| False | |
| >>> should_run_submake('--blah') | |
| True | |
| >>> should_run_submake('--random') | |
| True | |
| Both --dry-run and --question | |
| >>> should_run_submake('qn') | |
| False | |
| >>> should_run_submake('nq') | |
| False | |
| >>> should_run_submake('--quiant') | |
| True | |
| """ | |
| r = re.search(r'(?:^|\s)[^-]*(n|q)[^\s]*(\s|$)', make_flags) | |
| if not r: | |
| return True | |
| return not bool(r.groups()[0]) | |
| def main(argv): | |
| fuzzers_dir = os.path.abspath(os.path.dirname(__file__)) | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("fuzzer", help="fuzzer to run") | |
| parser.add_argument( | |
| "--retries", | |
| type=int, | |
| default=0, | |
| help="Retry a failed fuzzer n times.", | |
| ) | |
| args = parser.parse_args() | |
| # Setup the logger with flush=False, it should be safe to use in a signal | |
| # handler. | |
| fuzzer_length = max(len(f) for f in os.listdir(fuzzers_dir)) | |
| logger = Logger(args.fuzzer, datetime.utcnow(), fuzzer_length) | |
| # Need a signal handler on SIGCHLD otherwise get_resource doesn't return | |
| # anything. | |
| signal.signal(signal.SIGCHLD, lambda sig, frame: None) | |
| fuzzer_dir = os.path.join(fuzzers_dir, args.fuzzer) | |
| assert os.path.exists(fuzzer_dir), fuzzer_dir | |
| fuzzer_logdir = os.path.join(fuzzer_dir, "logs") | |
| if not os.path.exists(fuzzer_logdir): | |
| os.makedirs(fuzzer_logdir) | |
| assert os.path.exists(fuzzer_logdir) | |
| exit_code = -1 | |
| args.retries += 1 | |
| for retry_count in range(0, args.retries): | |
| logger.log('Running fuzzer attempt: {}', [retry_count]) | |
| exit_code = run_fuzzer( | |
| args.fuzzer, | |
| fuzzer_dir, | |
| fuzzer_logdir, | |
| logger, | |
| will_retry=retry_count < (args.retries - 1), | |
| ) | |
| if exit_code <= 0: | |
| break | |
| logger.log('WARNING: Fuzzer failed!') | |
| return exit_code | |
| def run_fuzzer(fuzzer_name, fuzzer_dir, fuzzer_logdir, logger, will_retry): | |
| def log(msg, *a, **k): | |
| logger.log(msg, a, k, flush=True) | |
| make_cmd = os.environ.get('MAKE', 'make') | |
| make_flags = os.environ.get('MAKEFLAGS', '') | |
| # Should run things? | |
| if not should_run_submake(make_flags): | |
| return 0 | |
| fuzzer_runok = os.path.join(fuzzer_dir, "run.ok") | |
| if os.path.exists(fuzzer_runok): | |
| last_modified = datetime.fromtimestamp(os.stat(fuzzer_runok).st_mtime) | |
| log("Skipping as run.ok exists (updated @ {})", | |
| last_modified.isoformat()) | |
| return 0 | |
| time_start = datetime.utcnow() | |
| log("Starting @ {}", time_start.isoformat()) | |
| running_msg = "Running {} -C {} run (with MAKEFLAGS='{}')".format( | |
| make_cmd, | |
| fuzzer_dir, | |
| make_flags, | |
| ) | |
| log(running_msg) | |
| log_suffix = ".{}.log".format(time_start.isoformat()) | |
| fuzzer_stdout = os.path.join(fuzzer_logdir, "stdout" + log_suffix) | |
| fuzzer_stderr = os.path.join(fuzzer_logdir, "stderr" + log_suffix) | |
| # Write header to stdout/stderr to make sure they match. | |
| for fname in [fuzzer_stdout, fuzzer_stderr]: | |
| with open(fname, "w") as fd: | |
| fd.write("Build starting @ {}\n".format(time_start.isoformat())) | |
| fd.write(running_msg) | |
| fd.write("\n") | |
| fd.write("-" * 75) | |
| fd.write("\n") | |
| fd.flush() | |
| os.fsync(fd) | |
| # Open the log files for appending | |
| stdout_fd = open(fuzzer_stdout, "a") | |
| stderr_fd = open(fuzzer_stderr, "a") | |
| # Play nice with make's jobserver. | |
| # See https://www.gnu.org/software/make/manual/html_node/POSIX-Jobserver.html#POSIX-Jobserver # noqa | |
| job_fds = [] | |
| if '--jobserver-fds' in make_flags: | |
| job_re = re.search('--jobserver-fds=([0-9]+),([0-9]+)', make_flags) | |
| assert job_re, make_flags | |
| job_rd, job_wr = job_re.groups() | |
| # Make copies of jobserver FDs in case a retry is needed. | |
| job_rd = int(job_rd) | |
| job_wr = int(job_wr) | |
| assert job_rd > 2, (job_rd, job_wr, make_flags) | |
| assert job_wr > 2, (job_rd, job_wr, make_flags) | |
| job_fds.append(job_rd) | |
| job_fds.append(job_wr) | |
| p = None | |
| try: | |
| p = subprocess.Popen( | |
| [make_cmd, '-C', fuzzer_dir, 'run'], | |
| stdin=None, | |
| stdout=stdout_fd, | |
| stderr=stderr_fd, | |
| pass_fds=job_fds, | |
| ) | |
| while True: | |
| try: | |
| retcode = p.wait(timeout=10) | |
| p = None | |
| except subprocess.TimeoutExpired: | |
| retcode = None | |
| if retcode is not None: | |
| break | |
| mem = get_memory()['mem'] | |
| log( | |
| "Still running (1m:{:0.2f}%, 5m:{:0.2f}%, 15m:{:0.2f}% Mem:{:0.1f}Gi used, {:0.1f}Gi free).\n{}", | |
| *get_load(), | |
| mem['used'] / 1e9, | |
| mem['available'] / | |
| 1e9, # Using available so the numbers add up. | |
| PsTree.get(p.pid), | |
| ) | |
| except (Exception, KeyboardInterrupt, SystemExit): | |
| retcode = -1 | |
| tb = io.StringIO() | |
| traceback.print_exc(file=tb) | |
| log(tb.getvalue()) | |
| # Prevent Ctrl-C so we exit properly... | |
| old_sigint_handler = signal.getsignal(signal.SIGINT) | |
| def log_die(sig, frame): | |
| logger.log("Dieing!") | |
| signal.signal(signal.SIGINT, log_die) | |
| # Cleanup child process if they haven't already died. | |
| try: | |
| if p is not None: | |
| try: | |
| retcode = p.wait(1) | |
| except subprocess.TimeoutExpired: | |
| retcode = -1 | |
| p.kill() | |
| p.wait() | |
| log("Warning: Killed program which should have been dead!") | |
| except Exception: | |
| tb = io.StringIO() | |
| traceback.print_exc(file=tb) | |
| log(tb.getvalue()) | |
| # Wait for all children to finish. | |
| try: | |
| while True: | |
| log("Child finished: {}", os.waitpid(-1, 0)) | |
| except ChildProcessError: | |
| pass | |
| log("Finishing ({}).", get_usage()) | |
| time_end = datetime.utcnow() | |
| error_log = "\n".join(last_lines(open(fuzzer_stderr), 10000)) | |
| success_log = "\n".join(last_lines(open(fuzzer_stdout), 100)) | |
| # Find the next X_sponge_log.xml file name... | |
| for i in range(0, 100): | |
| tsfilename = os.path.join(fuzzer_logdir, '{}_sponge_log.xml'.format(i)) | |
| if not os.path.exists(tsfilename): | |
| break | |
| test_case = junit_xml.TestCase( | |
| name=fuzzer_name, | |
| timestamp=time_start.timestamp(), | |
| elapsed_sec=(time_end - time_start).total_seconds(), | |
| stdout=success_log, | |
| stderr=error_log, | |
| ) | |
| if retcode != 0: | |
| test_case.add_failure_info( | |
| 'Fuzzer failed with exit code: {}'.format(retcode)) | |
| with open(tsfilename, 'w') as f: | |
| ts = junit_xml.TestSuite(fuzzer_name, [test_case]) | |
| junit_xml.TestSuite.to_file(f, [ts]) | |
| if retcode != 0: | |
| test_case.add_failure_info( | |
| 'Fuzzer failed with exit code: {}'.format(retcode), ) | |
| # Log the last 10,000 lines of stderr on a failure | |
| log("""\ | |
| Failed @ {time_end} with exit code: {retcode} | |
| -------------------------------------------------------------------------- | |
| {error_log} | |
| -------------------------------------------------------------------------- | |
| Failed @ {time_end} with exit code: {retcode} | |
| """, | |
| retcode=retcode, | |
| error_log=error_log, | |
| time_end=time_end.isoformat()) | |
| else: | |
| # Log the last 100 lines of a successful run | |
| log( | |
| """\ | |
| Succeeded! @ {} | |
| -------------------------------------------------------------------------- | |
| {} | |
| -------------------------------------------------------------------------- | |
| Succeeded! @ {} | |
| """, time_end.isoformat(), success_log, time_end.isoformat()) | |
| logger.flush() | |
| signal.signal(signal.SIGINT, old_sigint_handler) | |
| return retcode | |
| if __name__ == "__main__": | |
| if "--test" in sys.argv or len(sys.argv) == 1: | |
| import doctest | |
| doctest.testmod() | |
| else: | |
| sys.exit(main(sys.argv)) |