Skip to content

Commit a3030bf

Browse files
committed
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.
1 parent d036a3b commit a3030bf

2 files changed

Lines changed: 315 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,7 @@ CMakeUserPresets.json
6262
# files related to clangd cache
6363
.cache/*
6464

65+
# Generated by scripts/monitor_fuzz.py
66+
/fuzz.log
67+
6568
.venv/

scripts/monitor_fuzz.py

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2026 WebAssembly Community Group participants
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""Run and monitor the Binaryen fuzzer (fuzz_opt.py).
18+
19+
Monitors progress, manages log file truncation, stops at iteration limits,
20+
and reports bugs found.
21+
"""
22+
23+
import argparse
24+
import collections
25+
import os
26+
import re
27+
import signal
28+
import subprocess
29+
import sys
30+
import threading
31+
import time
32+
33+
try:
34+
import resource
35+
except ImportError:
36+
resource = None
37+
38+
39+
def set_stack_limit():
40+
"""Avoid stack overflows in interpreter by setting stack limit to unlimited."""
41+
if resource is None:
42+
return
43+
try:
44+
resource.setrlimit(
45+
resource.RLIMIT_STACK,
46+
(resource.RLIM_INFINITY, resource.RLIM_INFINITY),
47+
)
48+
except Exception:
49+
try:
50+
_, hard = resource.getrlimit(resource.RLIMIT_STACK)
51+
resource.setrlimit(resource.RLIMIT_STACK, (hard, hard))
52+
except Exception:
53+
pass
54+
55+
56+
class FuzzMonitor:
57+
"""Monitors fuzzer output stream, manages log files, and tracks state."""
58+
59+
def __init__(self, log_path, max_lines, keep_lines, truncate_interval):
60+
self.log_path = log_path
61+
self.max_lines = max_lines
62+
self.keep_lines = keep_lines
63+
self.truncate_interval = truncate_interval
64+
65+
self.lock = threading.Lock()
66+
self.latest_iteration = 0
67+
self.latest_seed = 'unknown'
68+
self.bug_found = False
69+
self.recent_lines = collections.deque(maxlen=20)
70+
71+
self.deque = collections.deque(maxlen=keep_lines)
72+
self.lines_written = 0
73+
74+
if os.path.isfile(log_path):
75+
try:
76+
with open(log_path, encoding='utf-8', errors='replace') as f:
77+
for line in f:
78+
self.deque.append(line)
79+
self.recent_lines.append(line)
80+
self.lines_written += 1
81+
self._parse_line(line)
82+
except Exception:
83+
pass
84+
85+
def _parse_line(self, line):
86+
iter_match = re.search(r'ITERATION:\s*(\d+)', line)
87+
if iter_match:
88+
self.latest_iteration = int(iter_match.group(1))
89+
90+
seed_match = re.search(r'seed:\s*(\d+)', line)
91+
if seed_match:
92+
self.latest_seed = seed_match.group(1)
93+
94+
if re.search(r'You found a bug', line, re.IGNORECASE):
95+
self.bug_found = True
96+
97+
def run(self, stdout_stream):
98+
last_truncate = time.time()
99+
try:
100+
with open(self.log_path, 'a', encoding='utf-8') as f:
101+
for line in stdout_stream:
102+
with self.lock:
103+
self._parse_line(line)
104+
self.deque.append(line)
105+
self.recent_lines.append(line)
106+
self.lines_written += 1
107+
108+
f.write(line)
109+
f.flush()
110+
111+
now = time.time()
112+
if (
113+
self.lines_written >= self.max_lines
114+
and (now - last_truncate) >= self.truncate_interval
115+
):
116+
f.close()
117+
with open(self.log_path, 'w', encoding='utf-8') as wf:
118+
with self.lock:
119+
wf.writelines(self.deque)
120+
self.lines_written = len(self.deque)
121+
f = open(self.log_path, 'a', encoding='utf-8')
122+
last_truncate = now
123+
except Exception as e:
124+
print(f'Error writing to log file: {e}', file=sys.stderr)
125+
126+
def get_progress(self):
127+
with self.lock:
128+
return self.latest_iteration
129+
130+
def get_status(self):
131+
with self.lock:
132+
return (
133+
self.bug_found,
134+
self.latest_iteration,
135+
self.latest_seed,
136+
list(self.recent_lines),
137+
)
138+
139+
140+
def parse_args():
141+
parser = argparse.ArgumentParser(description=__doc__)
142+
parser.add_argument(
143+
'--log-dir',
144+
default=os.environ.get('LOG_DIR', '.'),
145+
help='Directory to save fuzz.log (default: current directory or $LOG_DIR)',
146+
)
147+
parser.add_argument(
148+
'--max-iters',
149+
type=int,
150+
default=int(os.environ.get('MAX_ITERS', '0')),
151+
help='Stop after N iterations (0 for infinite, default: $MAX_ITERS or 0)',
152+
)
153+
parser.add_argument(
154+
'--truncate-interval',
155+
type=float,
156+
default=30.0,
157+
help='Seconds between log truncation checks (default: 30)',
158+
)
159+
parser.add_argument(
160+
'--max-lines',
161+
type=int,
162+
default=10000,
163+
help='Maximum lines in log before truncation (default: 10000)',
164+
)
165+
parser.add_argument(
166+
'--keep-lines',
167+
type=int,
168+
default=5000,
169+
help='Lines to keep when truncating (default: 5000)',
170+
)
171+
parser.add_argument(
172+
'cmd',
173+
nargs=argparse.REMAINDER,
174+
help='Fuzzer command to run (default: ./scripts/fuzz_opt.py)',
175+
)
176+
return parser.parse_args()
177+
178+
179+
def main():
180+
args = parse_args()
181+
182+
cmd = list(args.cmd)
183+
if cmd and cmd[0] == '--':
184+
cmd.pop(0)
185+
if not cmd:
186+
default_fuzzer = os.path.join(
187+
os.path.dirname(os.path.abspath(__file__)), 'fuzz_opt.py',
188+
)
189+
cmd = [sys.executable, default_fuzzer]
190+
191+
os.makedirs(args.log_dir, exist_ok=True)
192+
log_file_path = os.path.join(args.log_dir, 'fuzz.log')
193+
194+
set_stack_limit()
195+
196+
monitor = FuzzMonitor(
197+
log_path=log_file_path,
198+
max_lines=args.max_lines,
199+
keep_lines=args.keep_lines,
200+
truncate_interval=args.truncate_interval,
201+
)
202+
203+
env = os.environ.copy()
204+
env['PYTHONUNBUFFERED'] = '1'
205+
206+
proc = subprocess.Popen(
207+
cmd,
208+
stdout=subprocess.PIPE,
209+
stderr=subprocess.STDOUT,
210+
text=True,
211+
bufsize=1,
212+
env=env,
213+
errors='replace',
214+
start_new_session=True,
215+
)
216+
217+
print(f'Fuzzer started with PID {proc.pid}. Monitoring...', flush=True)
218+
219+
reader_thread = threading.Thread(
220+
target=monitor.run,
221+
args=(proc.stdout,),
222+
daemon=True,
223+
)
224+
reader_thread.start()
225+
226+
def stop_child():
227+
if proc.poll() is None:
228+
try:
229+
os.killpg(proc.pid, signal.SIGTERM)
230+
except ProcessLookupError:
231+
pass
232+
try:
233+
proc.wait(timeout=5)
234+
except subprocess.TimeoutExpired:
235+
try:
236+
os.killpg(proc.pid, signal.SIGKILL)
237+
except ProcessLookupError:
238+
pass
239+
proc.wait()
240+
241+
def signal_handler(signum, _frame):
242+
stop_child()
243+
reader_thread.join(timeout=2.0)
244+
sys.exit(128 + signum)
245+
246+
signal.signal(signal.SIGINT, signal_handler)
247+
signal.signal(signal.SIGTERM, signal_handler)
248+
249+
start_time = time.time()
250+
last_report = 0
251+
limit_reached = False
252+
253+
try:
254+
while reader_thread.is_alive() or proc.poll() is None:
255+
reader_thread.join(timeout=1.0)
256+
now = time.time()
257+
elapsed = int(now - start_time)
258+
259+
minute = elapsed // 60
260+
latest_iter = monitor.get_progress()
261+
262+
if minute > last_report:
263+
last_report = minute
264+
timestamp = time.strftime('%H:%M:%S')
265+
print(
266+
f'[{timestamp}] Runtime: {last_report} min, Latest'
267+
f' Iteration: {latest_iter}',
268+
flush=True,
269+
)
270+
271+
if args.max_iters > 0 and latest_iter >= args.max_iters:
272+
print(
273+
f'Reached max iterations ({args.max_iters}). Stopping'
274+
' fuzzer...',
275+
flush=True,
276+
)
277+
limit_reached = True
278+
stop_child()
279+
break
280+
finally:
281+
stop_child()
282+
reader_thread.join(timeout=5.0)
283+
284+
exit_code = proc.returncode
285+
286+
if limit_reached:
287+
print(
288+
f'SUCCESS: Reached max iterations ({args.max_iters}) without finding'
289+
' a bug.',
290+
)
291+
return 0
292+
293+
bug_found, iteration, seed, recent_lines = monitor.get_status()
294+
295+
if bug_found:
296+
print('SUCCESS: Bug found!')
297+
print(f'Iteration: {iteration}')
298+
print(f'Seed: {seed}')
299+
print(f'Exit code: {exit_code}')
300+
return 0
301+
302+
print('FAILURE: Fuzzer stopped unexpectedly without finding a bug.')
303+
print(f'Exit code: {exit_code}')
304+
if recent_lines:
305+
print('Last 20 lines of log:')
306+
for line in recent_lines:
307+
print(line.rstrip('\r\n'))
308+
return 1
309+
310+
311+
if __name__ == '__main__':
312+
sys.exit(main())

0 commit comments

Comments
 (0)