From a3030bf40085a062864cc3285297b6f0f7949350 Mon Sep 17 00:00:00 2001 From: Thomas Lively Date: Tue, 1 Sep 2026 23:12:45 -0700 Subject: [PATCH 1/2] Add a monitor_fuzz.py script This script runs and monitors fuzz_opt.py for up to a given number of iterations, redirecting its output to a rotating log file and printing its progress to stdout once a minute. When it detects that the fuzzer has found a bug, it prints the iteration number and the seed so the bug can be reproduced. This script is nicer to run in agent harnesses than raw fuzz_opt.py because agents can easily run the fuzzer for X iterations and can show the progress without overly polluting the context. --- .gitignore | 3 + scripts/monitor_fuzz.py | 312 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100755 scripts/monitor_fuzz.py diff --git a/.gitignore b/.gitignore index b88109b38e0..daa9178714f 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,7 @@ CMakeUserPresets.json # files related to clangd cache .cache/* +# Generated by scripts/monitor_fuzz.py +/fuzz.log + .venv/ diff --git a/scripts/monitor_fuzz.py b/scripts/monitor_fuzz.py new file mode 100755 index 00000000000..13e5d397f27 --- /dev/null +++ b/scripts/monitor_fuzz.py @@ -0,0 +1,312 @@ +#!/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 + +try: + import resource +except ImportError: + resource = None + + +def set_stack_limit(): + """Avoid stack overflows in interpreter by setting stack limit to unlimited.""" + if resource is None: + return + try: + resource.setrlimit( + resource.RLIMIT_STACK, + (resource.RLIM_INFINITY, resource.RLIM_INFINITY), + ) + except Exception: + try: + _, hard = resource.getrlimit(resource.RLIMIT_STACK) + resource.setrlimit(resource.RLIMIT_STACK, (hard, hard)) + except Exception: + pass + + +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.recent_lines.append(line) + self.lines_written += 1 + self._parse_line(line) + 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), + ) + + +def parse_args(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--log-dir', + default=os.environ.get('LOG_DIR', '.'), + help='Directory to save fuzz.log (default: current directory or $LOG_DIR)', + ) + parser.add_argument( + '--max-iters', + type=int, + default=int(os.environ.get('MAX_ITERS', '0')), + help='Stop after N iterations (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)', + ) + return parser.parse_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] + + os.makedirs(args.log_dir, exist_ok=True) + log_file_path = os.path.join(args.log_dir, 'fuzz.log') + + set_stack_limit() + + monitor = FuzzMonitor( + log_path=log_file_path, + max_lines=args.max_lines, + keep_lines=args.keep_lines, + truncate_interval=args.truncate_interval, + ) + + env = os.environ.copy() + env['PYTHONUNBUFFERED'] = '1' + + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env=env, + errors='replace', + start_new_session=True, + ) + + print(f'Fuzzer started with PID {proc.pid}. Monitoring...', flush=True) + + reader_thread = threading.Thread( + target=monitor.run, + args=(proc.stdout,), + daemon=True, + ) + reader_thread.start() + + def stop_child(): + if proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() + + def signal_handler(signum, _frame): + stop_child() + 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 + + try: + while reader_thread.is_alive() or proc.poll() is None: + reader_thread.join(timeout=1.0) + now = time.time() + elapsed = int(now - start_time) + + minute = elapsed // 60 + latest_iter = monitor.get_progress() + + if minute > last_report: + last_report = minute + timestamp = time.strftime('%H:%M:%S') + print( + f'[{timestamp}] Runtime: {last_report} min, Latest' + f' Iteration: {latest_iter}', + flush=True, + ) + + if args.max_iters > 0 and latest_iter >= args.max_iters: + print( + f'Reached max iterations ({args.max_iters}). Stopping' + ' fuzzer...', + flush=True, + ) + limit_reached = True + stop_child() + break + finally: + stop_child() + reader_thread.join(timeout=5.0) + + exit_code = proc.returncode + + if limit_reached: + print( + f'SUCCESS: Reached max iterations ({args.max_iters}) without finding' + ' a bug.', + ) + return 0 + + bug_found, iteration, seed, recent_lines = monitor.get_status() + + if bug_found: + print('SUCCESS: Bug found!') + print(f'Iteration: {iteration}') + print(f'Seed: {seed}') + print(f'Exit code: {exit_code}') + return 0 + + print('FAILURE: Fuzzer stopped unexpectedly without finding a bug.') + print(f'Exit code: {exit_code}') + if recent_lines: + print('Last 20 lines of log:') + for line in recent_lines: + print(line.rstrip('\r\n')) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) From 0b9bcf39027cbe28074ce14bcb5725fe88ba017f Mon Sep 17 00:00:00 2001 From: Thomas Lively Date: Wed, 2 Sep 2026 11:13:41 -0700 Subject: [PATCH 2/2] Move log file, stop setting stack limit --- .gitignore | 3 --- scripts/monitor_fuzz.py | 30 ++++-------------------------- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index daa9178714f..b88109b38e0 100644 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,4 @@ CMakeUserPresets.json # files related to clangd cache .cache/* -# Generated by scripts/monitor_fuzz.py -/fuzz.log - .venv/ diff --git a/scripts/monitor_fuzz.py b/scripts/monitor_fuzz.py index 13e5d397f27..e78501c1ed9 100755 --- a/scripts/monitor_fuzz.py +++ b/scripts/monitor_fuzz.py @@ -30,28 +30,6 @@ import threading import time -try: - import resource -except ImportError: - resource = None - - -def set_stack_limit(): - """Avoid stack overflows in interpreter by setting stack limit to unlimited.""" - if resource is None: - return - try: - resource.setrlimit( - resource.RLIMIT_STACK, - (resource.RLIM_INFINITY, resource.RLIM_INFINITY), - ) - except Exception: - try: - _, hard = resource.getrlimit(resource.RLIMIT_STACK) - resource.setrlimit(resource.RLIMIT_STACK, (hard, hard)) - except Exception: - pass - class FuzzMonitor: """Monitors fuzzer output stream, manages log files, and tracks state.""" @@ -138,11 +116,13 @@ def get_status(self): def parse_args(): + default_log_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), 'out', 'test') parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( '--log-dir', - default=os.environ.get('LOG_DIR', '.'), - help='Directory to save fuzz.log (default: current directory or $LOG_DIR)', + default=os.environ.get('LOG_DIR', default_log_dir), + help='Directory to save fuzz.log (default: $LOG_DIR or ./out/test)', ) parser.add_argument( '--max-iters', @@ -191,8 +171,6 @@ def main(): os.makedirs(args.log_dir, exist_ok=True) log_file_path = os.path.join(args.log_dir, 'fuzz.log') - set_stack_limit() - monitor = FuzzMonitor( log_path=log_file_path, max_lines=args.max_lines,