Skip to content

Commit 180bae3

Browse files
authored
Add a monitor_fuzz.py script (#9069)
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 6c4214f commit 180bae3

1 file changed

Lines changed: 290 additions & 0 deletions

File tree

scripts/monitor_fuzz.py

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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+
34+
class FuzzMonitor:
35+
"""Monitors fuzzer output stream, manages log files, and tracks state."""
36+
37+
def __init__(self, log_path, max_lines, keep_lines, truncate_interval):
38+
self.log_path = log_path
39+
self.max_lines = max_lines
40+
self.keep_lines = keep_lines
41+
self.truncate_interval = truncate_interval
42+
43+
self.lock = threading.Lock()
44+
self.latest_iteration = 0
45+
self.latest_seed = 'unknown'
46+
self.bug_found = False
47+
self.recent_lines = collections.deque(maxlen=20)
48+
49+
self.deque = collections.deque(maxlen=keep_lines)
50+
self.lines_written = 0
51+
52+
if os.path.isfile(log_path):
53+
try:
54+
with open(log_path, encoding='utf-8', errors='replace') as f:
55+
for line in f:
56+
self.deque.append(line)
57+
self.recent_lines.append(line)
58+
self.lines_written += 1
59+
self._parse_line(line)
60+
except Exception:
61+
pass
62+
63+
def _parse_line(self, line):
64+
iter_match = re.search(r'ITERATION:\s*(\d+)', line)
65+
if iter_match:
66+
self.latest_iteration = int(iter_match.group(1))
67+
68+
seed_match = re.search(r'seed:\s*(\d+)', line)
69+
if seed_match:
70+
self.latest_seed = seed_match.group(1)
71+
72+
if re.search(r'You found a bug', line, re.IGNORECASE):
73+
self.bug_found = True
74+
75+
def run(self, stdout_stream):
76+
last_truncate = time.time()
77+
try:
78+
with open(self.log_path, 'a', encoding='utf-8') as f:
79+
for line in stdout_stream:
80+
with self.lock:
81+
self._parse_line(line)
82+
self.deque.append(line)
83+
self.recent_lines.append(line)
84+
self.lines_written += 1
85+
86+
f.write(line)
87+
f.flush()
88+
89+
now = time.time()
90+
if (
91+
self.lines_written >= self.max_lines
92+
and (now - last_truncate) >= self.truncate_interval
93+
):
94+
f.close()
95+
with open(self.log_path, 'w', encoding='utf-8') as wf:
96+
with self.lock:
97+
wf.writelines(self.deque)
98+
self.lines_written = len(self.deque)
99+
f = open(self.log_path, 'a', encoding='utf-8')
100+
last_truncate = now
101+
except Exception as e:
102+
print(f'Error writing to log file: {e}', file=sys.stderr)
103+
104+
def get_progress(self):
105+
with self.lock:
106+
return self.latest_iteration
107+
108+
def get_status(self):
109+
with self.lock:
110+
return (
111+
self.bug_found,
112+
self.latest_iteration,
113+
self.latest_seed,
114+
list(self.recent_lines),
115+
)
116+
117+
118+
def parse_args():
119+
default_log_dir = os.path.join(
120+
os.path.dirname(os.path.abspath(__file__)), 'out', 'test')
121+
parser = argparse.ArgumentParser(description=__doc__)
122+
parser.add_argument(
123+
'--log-dir',
124+
default=os.environ.get('LOG_DIR', default_log_dir),
125+
help='Directory to save fuzz.log (default: $LOG_DIR or ./out/test)',
126+
)
127+
parser.add_argument(
128+
'--max-iters',
129+
type=int,
130+
default=int(os.environ.get('MAX_ITERS', '0')),
131+
help='Stop after N iterations (0 for infinite, default: $MAX_ITERS or 0)',
132+
)
133+
parser.add_argument(
134+
'--truncate-interval',
135+
type=float,
136+
default=30.0,
137+
help='Seconds between log truncation checks (default: 30)',
138+
)
139+
parser.add_argument(
140+
'--max-lines',
141+
type=int,
142+
default=10000,
143+
help='Maximum lines in log before truncation (default: 10000)',
144+
)
145+
parser.add_argument(
146+
'--keep-lines',
147+
type=int,
148+
default=5000,
149+
help='Lines to keep when truncating (default: 5000)',
150+
)
151+
parser.add_argument(
152+
'cmd',
153+
nargs=argparse.REMAINDER,
154+
help='Fuzzer command to run (default: ./scripts/fuzz_opt.py)',
155+
)
156+
return parser.parse_args()
157+
158+
159+
def main():
160+
args = parse_args()
161+
162+
cmd = list(args.cmd)
163+
if cmd and cmd[0] == '--':
164+
cmd.pop(0)
165+
if not cmd:
166+
default_fuzzer = os.path.join(
167+
os.path.dirname(os.path.abspath(__file__)), 'fuzz_opt.py',
168+
)
169+
cmd = [sys.executable, default_fuzzer]
170+
171+
os.makedirs(args.log_dir, exist_ok=True)
172+
log_file_path = os.path.join(args.log_dir, 'fuzz.log')
173+
174+
monitor = FuzzMonitor(
175+
log_path=log_file_path,
176+
max_lines=args.max_lines,
177+
keep_lines=args.keep_lines,
178+
truncate_interval=args.truncate_interval,
179+
)
180+
181+
env = os.environ.copy()
182+
env['PYTHONUNBUFFERED'] = '1'
183+
184+
proc = subprocess.Popen(
185+
cmd,
186+
stdout=subprocess.PIPE,
187+
stderr=subprocess.STDOUT,
188+
text=True,
189+
bufsize=1,
190+
env=env,
191+
errors='replace',
192+
start_new_session=True,
193+
)
194+
195+
print(f'Fuzzer started with PID {proc.pid}. Monitoring...', flush=True)
196+
197+
reader_thread = threading.Thread(
198+
target=monitor.run,
199+
args=(proc.stdout,),
200+
daemon=True,
201+
)
202+
reader_thread.start()
203+
204+
def stop_child():
205+
if proc.poll() is None:
206+
try:
207+
os.killpg(proc.pid, signal.SIGTERM)
208+
except ProcessLookupError:
209+
pass
210+
try:
211+
proc.wait(timeout=5)
212+
except subprocess.TimeoutExpired:
213+
try:
214+
os.killpg(proc.pid, signal.SIGKILL)
215+
except ProcessLookupError:
216+
pass
217+
proc.wait()
218+
219+
def signal_handler(signum, _frame):
220+
stop_child()
221+
reader_thread.join(timeout=2.0)
222+
sys.exit(128 + signum)
223+
224+
signal.signal(signal.SIGINT, signal_handler)
225+
signal.signal(signal.SIGTERM, signal_handler)
226+
227+
start_time = time.time()
228+
last_report = 0
229+
limit_reached = False
230+
231+
try:
232+
while reader_thread.is_alive() or proc.poll() is None:
233+
reader_thread.join(timeout=1.0)
234+
now = time.time()
235+
elapsed = int(now - start_time)
236+
237+
minute = elapsed // 60
238+
latest_iter = monitor.get_progress()
239+
240+
if minute > last_report:
241+
last_report = minute
242+
timestamp = time.strftime('%H:%M:%S')
243+
print(
244+
f'[{timestamp}] Runtime: {last_report} min, Latest'
245+
f' Iteration: {latest_iter}',
246+
flush=True,
247+
)
248+
249+
if args.max_iters > 0 and latest_iter >= args.max_iters:
250+
print(
251+
f'Reached max iterations ({args.max_iters}). Stopping'
252+
' fuzzer...',
253+
flush=True,
254+
)
255+
limit_reached = True
256+
stop_child()
257+
break
258+
finally:
259+
stop_child()
260+
reader_thread.join(timeout=5.0)
261+
262+
exit_code = proc.returncode
263+
264+
if limit_reached:
265+
print(
266+
f'SUCCESS: Reached max iterations ({args.max_iters}) without finding'
267+
' a bug.',
268+
)
269+
return 0
270+
271+
bug_found, iteration, seed, recent_lines = monitor.get_status()
272+
273+
if bug_found:
274+
print('SUCCESS: Bug found!')
275+
print(f'Iteration: {iteration}')
276+
print(f'Seed: {seed}')
277+
print(f'Exit code: {exit_code}')
278+
return 0
279+
280+
print('FAILURE: Fuzzer stopped unexpectedly without finding a bug.')
281+
print(f'Exit code: {exit_code}')
282+
if recent_lines:
283+
print('Last 20 lines of log:')
284+
for line in recent_lines:
285+
print(line.rstrip('\r\n'))
286+
return 1
287+
288+
289+
if __name__ == '__main__':
290+
sys.exit(main())

0 commit comments

Comments
 (0)