Skip to main content

max / makenotwork

custom-pages: replay regressions on a thread with room to recurse The regression suite aborted in a debug build, on fw13 and astra alike. An input can flatten to a stylesheet far larger and more deeply nested than itself -- 214 bytes to 219 KB, measured -- and the oracle's reparse of that recurses per nesting level. Debug frames are several times release frames, so the same inputs that pass under --release overflow a default 2 MiB test-thread stack. Found by a cargo mutants BASELINE, not by a test run, and that is the part worth keeping: a stack overflow aborts the process before libtest prints a result line, so `cargo test | grep "test result"` showed the lib suite passing and said nothing whatever about this one. The check that caught it was a tool refusing to mutate a tree whose tests do not pass. Same fix as the css fuzz target and for the same reason. A spawned thread is also the only option available: an overflow aborts, and catch_unwind cannot see it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 12:27 UTC
Signed with PGP, not checked
Commit: c993e9150bd7429207fc213df98085ec3fef08de
Parent: 39980b2
1 file changed, +29 insertions, -15 deletions
@@ -37,7 +37,22 @@
37 37 ///
38 38 /// A missing directory is fine and returns zero: `fuzz/regressions/` does not
39 39 /// exist until the first crash does.
40 - fn replay(dir: &Path, check: impl Fn(&str) + Copy + std::panic::RefUnwindSafe) -> usize {
40 + ///
41 + /// **Each input is replayed on its own thread, with a large stack**, for the
42 + /// reason the css fuzz target spawns one too: an input can flatten to a
43 + /// stylesheet far larger and more deeply nested than itself (214 bytes to
44 + /// 219 KB, measured), and the oracle's reparse of that recurses per nesting
45 + /// level. A debug build's frames are several times a release build's, so this
46 + /// suite aborted on a default 2 MiB test-thread stack while the same inputs
47 + /// passed under `--release`. It aborted on both fw13 and astra, and was found
48 + /// by a `cargo mutants` BASELINE rather than by a test run, because a stack
49 + /// overflow kills the process before libtest can print a result line -- so
50 + /// `cargo test | grep "test result"` showed the lib suite passing and said
51 + /// nothing at all about this one.
52 + ///
53 + /// A spawned thread is also the only option: a stack overflow aborts the
54 + /// process and `catch_unwind` cannot see it.
55 + fn replay(dir: &Path, check: impl Fn(&str) + Copy + Send + 'static) -> usize {
41 56 let Ok(entries) = std::fs::read_dir(dir) else {
42 57 return 0;
43 58 };
@@ -58,15 +73,15 @@
58 73 let Ok(text) = std::str::from_utf8(&bytes) else {
59 74 continue;
60 75 };
76 + let owned = text.to_string();
61 77
62 - // Panics here name the file: a bare oracle panic in a loop over twenty
78 + let handle = std::thread::Builder::new()
79 + .stack_size(256 * 1024 * 1024)
80 + .spawn(move || check(&owned))
81 + .expect("spawning the replay thread");
82 + // Naming the file matters: a bare oracle panic in a loop over twenty
63 83 // inputs says nothing about which one broke.
64 - let outcome = std::panic::catch_unwind(|| check(text));
65 - assert!(
66 - outcome.is_ok(),
67 - "oracle failed on {}: {text:?}",
68 - path.display()
69 - );
84 + assert!(handle.join().is_ok(), "oracle failed on {}", path.display());
70 85 count += 1;
71 86 }
72 87 count
@@ -77,10 +92,11 @@
77 92 /// running both costs nothing and removes the question of which subdirectory a
78 93 /// new artifact belongs in.
79 94 fn replay_both(dir: &Path) -> usize {
80 - let policy = policy();
81 - let html = replay(dir, |s: &str| custom_pages::oracle::check_html(s, &policy));
95 + let html = replay(dir, |s: &str| {
96 + custom_pages::oracle::check_html(s, &policy())
97 + });
82 98 let css = replay(dir, |s: &str| {
83 - custom_pages::oracle::check_css(s, OWNER_SCOPE, &policy);
99 + custom_pages::oracle::check_css(s, OWNER_SCOPE, &policy());
84 100 });
85 101 assert_eq!(html, css, "the same directory yielded two different counts");
86 102 html
@@ -88,24 +104,22 @@
88 104
89 105 #[test]
90 106 fn html_seeds_satisfy_the_oracle() {
91 - let policy = policy();
92 107 let n = replay(
93 108 Path::new(env!("CARGO_MANIFEST_DIR"))
94 109 .join("fuzz/seeds/html")
95 110 .as_path(),
96 - |s: &str| custom_pages::oracle::check_html(s, &policy),
111 + |s: &str| custom_pages::oracle::check_html(s, &policy()),
97 112 );
98 113 assert!(n > 0, "no HTML seeds found; the corpus is the point");
99 114 }
100 115
101 116 #[test]
102 117 fn css_seeds_satisfy_the_oracle() {
103 - let policy = policy();
104 118 let n = replay(
105 119 Path::new(env!("CARGO_MANIFEST_DIR"))
106 120 .join("fuzz/seeds/css")
107 121 .as_path(),
108 - |s: &str| custom_pages::oracle::check_css(s, OWNER_SCOPE, &policy),
122 + |s: &str| custom_pages::oracle::check_css(s, OWNER_SCOPE, &policy()),
109 123 );
110 124 assert!(n > 0, "no CSS seeds found; the corpus is the point");
111 125 }