| #!/usr/bin/env python3 |
| |
| # Copyright 2026 WebAssembly Community Group participants |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| """Run and monitor the Binaryen fuzzer (fuzz_opt.py). |
| |
| Monitors progress, manages log file truncation, stops at iteration limits, |
| and reports bugs found. |
| """ |
| |
| import argparse |
| import collections |
| import os |
| import re |
| import signal |
| import subprocess |
| import sys |
| import threading |
| import time |
| |
| |
| # Duplicated from test.shared (importing test.shared has unwanted side effects). |
| def get_num_cores(): |
| # Prefer `os.process_cpu_count` when available (3.13 and above) since it |
| # takes into account thread affinity. Fall back to `os.sched_getaffinity` |
| # where available and finally `os.cpu_count`, which should work everywhere. |
| if hasattr(os, 'process_cpu_count'): |
| cpu_count = os.process_cpu_count() |
| elif hasattr(os, 'sched_getaffinity'): |
| cpu_count = len(os.sched_getaffinity(0)) |
| else: |
| cpu_count = os.cpu_count() |
| return int(os.getenv('BINARYEN_CORES', cpu_count)) |
| |
| |
| class FuzzMonitor: |
| """Monitors fuzzer output stream, manages log files, and tracks state.""" |
| |
| def __init__(self, log_path, max_lines, keep_lines, truncate_interval): |
| self.log_path = log_path |
| self.max_lines = max_lines |
| self.keep_lines = keep_lines |
| self.truncate_interval = truncate_interval |
| |
| self.lock = threading.Lock() |
| self.latest_iteration = 0 |
| self.latest_seed = 'unknown' |
| self.bug_found = False |
| self.recent_lines = collections.deque(maxlen=20) |
| |
| self.deque = collections.deque(maxlen=keep_lines) |
| self.lines_written = 0 |
| |
| if os.path.isfile(log_path): |
| try: |
| with open(log_path, encoding='utf-8', errors='replace') as f: |
| for line in f: |
| self.deque.append(line) |
| self.lines_written += 1 |
| except Exception: |
| pass |
| |
| def _parse_line(self, line): |
| iter_match = re.search(r'ITERATION:\s*(\d+)', line) |
| if iter_match: |
| self.latest_iteration = int(iter_match.group(1)) |
| |
| seed_match = re.search(r'seed:\s*(\d+)', line) |
| if seed_match: |
| self.latest_seed = seed_match.group(1) |
| |
| if re.search(r'You found a bug', line, re.IGNORECASE): |
| self.bug_found = True |
| |
| def run(self, stdout_stream): |
| last_truncate = time.time() |
| try: |
| with open(self.log_path, 'a', encoding='utf-8') as f: |
| for line in stdout_stream: |
| with self.lock: |
| self._parse_line(line) |
| self.deque.append(line) |
| self.recent_lines.append(line) |
| self.lines_written += 1 |
| |
| f.write(line) |
| f.flush() |
| |
| now = time.time() |
| if ( |
| self.lines_written >= self.max_lines |
| and (now - last_truncate) >= self.truncate_interval |
| ): |
| f.close() |
| with open(self.log_path, 'w', encoding='utf-8') as wf: |
| with self.lock: |
| wf.writelines(self.deque) |
| self.lines_written = len(self.deque) |
| f = open(self.log_path, 'a', encoding='utf-8') |
| last_truncate = now |
| except Exception as e: |
| print(f'Error writing to log file: {e}', file=sys.stderr) |
| |
| def get_progress(self): |
| with self.lock: |
| return self.latest_iteration |
| |
| def get_status(self): |
| with self.lock: |
| return ( |
| self.bug_found, |
| self.latest_iteration, |
| self.latest_seed, |
| list(self.recent_lines), |
| ) |
| |
| |
| class FuzzerWorker: |
| """Manages a single fuzzer subprocess and its monitor.""" |
| |
| def __init__( |
| self, |
| worker_id, |
| work_dir, |
| cmd, |
| env, |
| max_lines, |
| keep_lines, |
| truncate_interval, |
| ): |
| self.id = worker_id |
| self.work_dir = work_dir |
| os.makedirs(work_dir, exist_ok=True) |
| self.log_path = os.path.join(work_dir, 'fuzz.log') |
| self.monitor = FuzzMonitor( |
| log_path=self.log_path, |
| max_lines=max_lines, |
| keep_lines=keep_lines, |
| truncate_interval=truncate_interval, |
| ) |
| worker_env = env.copy() |
| worker_env['BINARYEN_OUT_DIR'] = work_dir |
| self.proc = subprocess.Popen( |
| cmd, |
| cwd=work_dir, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| text=True, |
| bufsize=1, |
| env=worker_env, |
| errors='replace', |
| start_new_session=True, |
| ) |
| self.reader_thread = threading.Thread( |
| target=self.monitor.run, |
| args=(self.proc.stdout,), |
| daemon=True, |
| ) |
| self.reader_thread.start() |
| |
| |
| def parse_args(): |
| # N.B. We could alternatively `import shared from test`, which has the side |
| # effect of changing the current directory to <binaryen_root>/out/test, but |
| # this is less magical. |
| binaryen_root = os.path.dirname( |
| os.path.dirname(os.path.abspath(__file__))) |
| default_log_dir = os.path.join(binaryen_root, 'out', 'test') |
| cores = get_num_cores() or 1 |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument( |
| '-j', |
| '--jobs', |
| type=int, |
| nargs='?', |
| const=cores, |
| default=int(os.environ.get('JOBS', '1')), |
| help=( |
| f'Number of parallel fuzzers to run (default: $JOBS or 1; ' |
| f'defaults to {cores} if passed without an argument)' |
| ), |
| ) |
| parser.add_argument( |
| '--log-dir', |
| default=os.environ.get('LOG_DIR', default_log_dir), |
| help='Directory to save fuzz logs (default: $LOG_DIR or out/test)', |
| ) |
| parser.add_argument( |
| '--max-iters', |
| type=int, |
| default=int(os.environ.get('MAX_ITERS', '0')), |
| help='Stop after N total iterations across all fuzzers (0 for infinite, default: $MAX_ITERS or 0)', |
| ) |
| parser.add_argument( |
| '--truncate-interval', |
| type=float, |
| default=30.0, |
| help='Seconds between log truncation checks (default: 30)', |
| ) |
| parser.add_argument( |
| '--max-lines', |
| type=int, |
| default=10000, |
| help='Maximum lines in log before truncation (default: 10000)', |
| ) |
| parser.add_argument( |
| '--keep-lines', |
| type=int, |
| default=5000, |
| help='Lines to keep when truncating (default: 5000)', |
| ) |
| parser.add_argument( |
| 'cmd', |
| nargs=argparse.REMAINDER, |
| help='Fuzzer command to run (default: ./scripts/fuzz_opt.py)', |
| ) |
| args = parser.parse_args() |
| if args.jobs < 1: |
| parser.error('--jobs must be at least 1') |
| return args |
| |
| |
| def main(): |
| args = parse_args() |
| |
| cmd = list(args.cmd) |
| if cmd and cmd[0] == '--': |
| cmd.pop(0) |
| if not cmd: |
| default_fuzzer = os.path.join( |
| os.path.dirname(os.path.abspath(__file__)), 'fuzz_opt.py', |
| ) |
| cmd = [sys.executable, default_fuzzer] |
| else: |
| cmd = [ |
| os.path.abspath(arg) if os.path.exists(arg) else arg for arg in cmd |
| ] |
| |
| env = os.environ.copy() |
| env['PYTHONUNBUFFERED'] = '1' |
| |
| workers = [] |
| for i in range(args.jobs): |
| work_dir = os.path.join(args.log_dir, str(i)) |
| workers.append( |
| FuzzerWorker( |
| worker_id=i, |
| work_dir=work_dir, |
| cmd=cmd, |
| env=env, |
| max_lines=args.max_lines, |
| keep_lines=args.keep_lines, |
| truncate_interval=args.truncate_interval, |
| ), |
| ) |
| |
| if len(workers) == 1: |
| print( |
| f'Fuzzer started with PID {workers[0].proc.pid}. Monitoring...', |
| flush=True, |
| ) |
| else: |
| pids = ', '.join(str(w.proc.pid) for w in workers) |
| print( |
| f'Started {len(workers)} fuzzers with PIDs {pids}. Monitoring...', |
| flush=True, |
| ) |
| |
| def stop_children(): |
| for w in workers: |
| if w.proc.poll() is None: |
| try: |
| os.killpg(w.proc.pid, signal.SIGTERM) |
| except ProcessLookupError: |
| pass |
| deadline = time.time() + 5.0 |
| for w in workers: |
| if w.proc.poll() is None: |
| remaining = max(0.0, deadline - time.time()) |
| try: |
| w.proc.wait(timeout=remaining) |
| except subprocess.TimeoutExpired: |
| try: |
| os.killpg(w.proc.pid, signal.SIGKILL) |
| except ProcessLookupError: |
| pass |
| w.proc.wait() |
| |
| def signal_handler(signum, _frame): |
| stop_children() |
| for w in workers: |
| w.reader_thread.join(timeout=2.0) |
| sys.exit(128 + signum) |
| |
| signal.signal(signal.SIGINT, signal_handler) |
| signal.signal(signal.SIGTERM, signal_handler) |
| |
| start_time = time.time() |
| last_report = 0 |
| limit_reached = False |
| stopped_worker = None |
| |
| try: |
| while any( |
| w.reader_thread.is_alive() or w.proc.poll() is None for w in workers |
| ): |
| time.sleep(0.2) |
| now = time.time() |
| elapsed = int(now - start_time) |
| |
| minute = elapsed // 60 |
| total_iters = sum(w.monitor.get_progress() for w in workers) |
| |
| if minute > last_report: |
| last_report = minute |
| timestamp = time.strftime('%H:%M:%S') |
| print( |
| f'[{timestamp}] Runtime: {last_report} min,' |
| f' Iterations: {total_iters}', |
| flush=True, |
| ) |
| |
| if args.max_iters > 0 and total_iters >= args.max_iters: |
| fuzzer_str = 'fuzzer' if len(workers) == 1 else 'fuzzers' |
| print( |
| f'Reached max iterations ({args.max_iters}). Stopping' |
| f' {fuzzer_str}...', |
| flush=True, |
| ) |
| limit_reached = True |
| stop_children() |
| break |
| |
| should_stop = False |
| for w in workers: |
| if w.monitor.get_status()[0]: |
| try: |
| w.proc.wait(timeout=2.0) |
| except subprocess.TimeoutExpired: |
| pass |
| w.reader_thread.join(timeout=2.0) |
| stopped_worker = w |
| should_stop = True |
| break |
| if w.proc.poll() is not None: |
| w.reader_thread.join(timeout=2.0) |
| stopped_worker = w |
| should_stop = True |
| break |
| |
| if should_stop: |
| stop_children() |
| break |
| finally: |
| stop_children() |
| for w in workers: |
| w.reader_thread.join(timeout=5.0) |
| |
| for w in workers: |
| bug_found, iteration, seed, _ = w.monitor.get_status() |
| if bug_found: |
| print('SUCCESS: Bug found!') |
| if len(workers) > 1: |
| print(f'Fuzzer: {w.id}') |
| print(f'Directory: {w.work_dir}') |
| print(f'Iteration: {iteration}') |
| print(f'Seed: {seed}') |
| print(f'Exit code: {w.proc.returncode}') |
| return 1 |
| |
| if limit_reached: |
| print( |
| f'SUCCESS: Reached max iterations ({args.max_iters}) without finding' |
| ' a bug.', |
| ) |
| return 0 |
| |
| failed_worker = stopped_worker or workers[0] |
| _, _, _, recent_lines = failed_worker.monitor.get_status() |
| |
| print('FAILURE: Fuzzer stopped unexpectedly without finding a bug.') |
| if len(workers) > 1: |
| print(f'Fuzzer: {failed_worker.id}') |
| print(f'Directory: {failed_worker.work_dir}') |
| print(f'Exit code: {failed_worker.proc.returncode}') |
| if recent_lines: |
| print('Last 20 lines of log:') |
| for line in recent_lines: |
| print(line.rstrip('\r\n')) |
| return 2 |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |