Skip to content

Commit 2f77ea6

Browse files
timfennisclaude
andauthored
🎲 test: load stdlib in panic-fuzz and add lexer-panic proptest (#135)
## Summary - **Stdlib in panic-fuzz**: `tests/proptest/tests/panic.rs` now builds a `FunctionRegistry`, registers the stdlib, and feeds `(name, StaticType)` pairs to the analyser scope plus matching `Vec<VmValue>` (same iteration order so global slots line up) to `Vm::new`. Random programs that reference built-ins (`print`, `len`, …) now reach the analyser/compiler/VM instead of being rejected up front. Registry is rebuilt per case (`Rc<NativeFunction>` is `!Send`); release-mode cost is negligible — 1M cases in ~66s. - **Lexer-panic proptest**: new `tests/proptest/tests/lexer_panic.rs` mirrors the panic.rs structure (regression-replay default test + `#[ignore]`d fuzz test) but generates random `String` inputs. Char strategy is weighted toward lexer-trigger bytes (`"`, `\`, `#`, `r`, digit prefixes/suffixes, operators, comment starts) plus full unicode for multi-byte offset coverage. - **`Number::pow` exponent guard**: first 1M-case run with stdlib loaded shrank to `(-2) ^ i128::MAX`, which calls `num::pow::Pow` on a multi-billion-bit magnitude and never returns (the author had flagged this inline in `int.rs:120` but no guard existed). Now bails with `BinaryOperatorError("exponent too large to compute")` when the exponent magnitude exceeds 2^32 bits. - **Regressions**: `tests/functional/programs/900_bugs/bug0020_pow_huge_exponent.ndc` pins the user-visible error, and the proptest seed in `panic.regressions` guards against the underlying hang. ## Notes for reviewers - 32-bit exponent ceiling is generous (~4 billion); any realistic exponent is orders of magnitude below this. - Lexer fuzzer found nothing at 100k cases — currently functions as a regression net rather than a bug source. The setup is in place to escalate (`PROPTEST_CASES`, `MAX_INPUT_LEN`). - The two test files share helpers (timeout/quiet panic hook); kept them duplicated for now since each test crate file is its own binary. Happy to dedup into a `common/` module if preferred. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 1b024a4 commit 2f77ea6

7 files changed

Lines changed: 233 additions & 7 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎ndc_core/src/num.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,21 @@ impl Number {
404404
}
405405

406406
pub fn pow(self, rhs: Self) -> Result<Self, BinaryOperatorError> {
407+
// Reject astronomically large integer exponents up front: an exponent
408+
// that doesn't fit in u32 would produce a result too large to compute
409+
// in finite time. Without this guard, `2 ^ i64::MAX` hangs the VM.
410+
const MAX_EXPONENT_BITS: u64 = 32;
411+
let too_large = match &rhs {
412+
Self::Int(Int::BigInt(b)) => b.magnitude().bits() > MAX_EXPONENT_BITS,
413+
Self::Rational(p) if p.is_integer() => p.numer().magnitude().bits() > MAX_EXPONENT_BITS,
414+
_ => false,
415+
};
416+
if too_large {
417+
return Err(BinaryOperatorError::new(
418+
"exponent too large to compute".to_string(),
419+
));
420+
}
421+
407422
Ok(match (self, rhs) {
408423
// Int vs others
409424
(Self::Int(p1), Self::Int(p2)) => {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Found by the panic.rs proptest fuzzer with stdlib loaded.
2+
// `2 ^ i128::MAX` would call num::pow::Pow on a multi-billion-bit
3+
// magnitude, hanging the VM indefinitely. Now bails with an error
4+
// instead of computing a result that wouldn't fit in any reasonable
5+
// amount of memory.
6+
// expect-error: exponent too large
7+
print(-2 ^ 170141183460469231731687303715884105727)

‎tests/proptest/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ version.workspace = true
55

66
[dev-dependencies]
77
ndc_analyser.workspace = true
8+
ndc_core.workspace = true
89
ndc_lexer.workspace = true
910
ndc_parser.workspace = true
11+
ndc_stdlib.workspace = true
1012
ndc_vm.workspace = true
1113
num.workspace = true
1214
proptest.workspace = true
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//! Property-based panic test for the Andy C++ lexer.
2+
//!
3+
//! Generates random `String` inputs and pushes them through `Lexer::new(...)`,
4+
//! draining the iterator. Errors are expected and ignored; only panics,
5+
//! `unwrap`s, `expect`s, and `unreachable!`s count as failures.
6+
//!
7+
//! Each generated input runs in a worker thread with a 500ms timeout. The
8+
//! lexer always advances by at least one character per `next()` call, so a
9+
//! genuine hang would indicate a regression — the timeout exists as a
10+
//! safety net rather than because we expect to hit it.
11+
//!
12+
//! Two test functions share `lexer_panic.regressions`:
13+
//!
14+
//! * `known_regressions_do_not_panic` runs by default — it generates 0 fresh
15+
//! cases and only replays seeds saved in the regressions file, guarding
16+
//! against future commits that resurrect a known panic.
17+
//! * `fuzz_random_strings` is `#[ignore]`d. Opt in with:
18+
//!
19+
//! ```sh
20+
//! cargo test -p proptest_tests -- --ignored # fuzz only
21+
//! cargo test -p proptest_tests -- --include-ignored # regressions + fuzz
22+
//! PROPTEST_CASES=100000 cargo test -p proptest_tests -- --ignored
23+
//! ```
24+
25+
use ndc_analyser as _;
26+
use ndc_core as _;
27+
use ndc_lexer::{Lexer, SourceId};
28+
use ndc_parser as _;
29+
use ndc_stdlib as _;
30+
use ndc_vm as _;
31+
use num as _;
32+
use proptest::prelude::*;
33+
use proptest::test_runner::FileFailurePersistence;
34+
use std::fmt;
35+
use std::panic::{self, AssertUnwindSafe};
36+
use std::sync::Once;
37+
use std::sync::mpsc;
38+
use std::thread;
39+
use std::time::Duration;
40+
41+
const PER_CASE_TIMEOUT: Duration = Duration::from_millis(500);
42+
const MAX_INPUT_LEN: usize = 200;
43+
44+
/// Wrapper around the generated input with a compact `Debug` impl that
45+
/// shows byte length plus the escaped input, so a failure prints
46+
/// `[12B] "0r\"\\#"` instead of a wall of escapes.
47+
#[derive(Clone)]
48+
struct Source(String);
49+
50+
impl fmt::Debug for Source {
51+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52+
write!(f, "[{}B] {:?}", self.0.len(), self.0)
53+
}
54+
}
55+
56+
const WORKER_THREAD_NAME: &str = "proptest-lexer-fuzz-worker";
57+
58+
/// Replace the default panic hook with one that stays silent on the named
59+
/// worker thread but delegates to the original hook everywhere else.
60+
/// Without this, every shrinking attempt floods stderr with the same
61+
/// panic message; with it, only the final shrunk failure (resumed on the
62+
/// test thread) is printed.
63+
fn install_quiet_panic_hook() {
64+
static HOOK: Once = Once::new();
65+
HOOK.call_once(|| {
66+
let default_hook = panic::take_hook();
67+
panic::set_hook(Box::new(move |info| {
68+
if thread::current().name() != Some(WORKER_THREAD_NAME) {
69+
default_hook(info);
70+
}
71+
}));
72+
});
73+
}
74+
75+
/// Single character drawn from a weighted union biased toward bytes that
76+
/// exercise lexer branches:
77+
/// - delimiters and escape: `"`, `\`, `#`
78+
/// - raw-string starter and number suffixes: `r`, `i`, `j`
79+
/// - number prefixes/separators: digits, `0`, `b`, `x`, `o`, `_`, `.`
80+
/// - identifier and operator chars
81+
/// - comment starts: `/`, `#`, `!`
82+
/// - whitespace incl. `\r` and `\n`
83+
///
84+
/// The other arms add general ASCII printable and full-range unicode so
85+
/// multi-byte offset arithmetic gets exercised too.
86+
fn arb_char() -> impl Strategy<Value = char> {
87+
let high_signal: Vec<char> = vec![
88+
'"', '\\', '#', 'r', '.', '_', '?', '+', '-', '*', '/', '%', '=', '<', '>', '!', '&', '|',
89+
'^', '~', '(', ')', '[', ']', '{', '}', ',', ';', ':', ' ', '\t', '\n', '\r', 'a', 'b',
90+
'c', 'f', 'g', 'i', 'j', 'n', 'o', 'x', 'z', '0', '1', '2', '7', '9',
91+
];
92+
93+
prop_oneof![
94+
20 => prop::sample::select(high_signal),
95+
// Any ASCII printable, to widen coverage of the single-char token try-from path.
96+
4 => (32u32..=126u32).prop_filter_map("ascii printable", char::from_u32),
97+
// Full unicode range minus surrogates and out-of-range codepoints.
98+
1 => (0u32..=0x10_FFFF).prop_filter_map("valid scalar value", char::from_u32),
99+
]
100+
}
101+
102+
fn arb_source() -> impl Strategy<Value = Source> {
103+
prop::collection::vec(arb_char(), 0..=MAX_INPUT_LEN)
104+
.prop_map(|chars| Source(chars.into_iter().collect()))
105+
}
106+
107+
/// Drain the lexer over `input`. Returns `()` on any error because the
108+
/// test only cares about panics, not error variants.
109+
fn run_lexer(input: &str) {
110+
for token in Lexer::new(input, SourceId::new(0)) {
111+
let _ = token;
112+
}
113+
}
114+
115+
/// Run the lexer in a worker thread with a hard timeout. Both genuine
116+
/// panics and timeouts propagate to the proptest harness so it can shrink
117+
/// them.
118+
fn run_with_timeout(source: Source) {
119+
install_quiet_panic_hook();
120+
let (tx, rx) = mpsc::channel();
121+
thread::Builder::new()
122+
.name(WORKER_THREAD_NAME.into())
123+
.spawn(move || {
124+
let result = panic::catch_unwind(AssertUnwindSafe(|| run_lexer(&source.0)));
125+
let _ = tx.send(result);
126+
})
127+
.expect("spawn worker thread");
128+
129+
match rx.recv_timeout(PER_CASE_TIMEOUT) {
130+
Err(mpsc::RecvTimeoutError::Timeout) => panic!(
131+
"lexer did not terminate within {} ms",
132+
PER_CASE_TIMEOUT.as_millis()
133+
),
134+
Ok(Err(payload)) => panic::resume_unwind(payload),
135+
Ok(Ok(())) | Err(mpsc::RecvTimeoutError::Disconnected) => {}
136+
}
137+
}
138+
139+
fn persistence_config(cases: u32, max_shrink_iters: u32) -> ProptestConfig {
140+
ProptestConfig {
141+
cases,
142+
max_shrink_iters,
143+
failure_persistence: Some(Box::new(FileFailurePersistence::WithSource("regressions"))),
144+
..ProptestConfig::default()
145+
}
146+
}
147+
148+
proptest! {
149+
// cases=0 skips fresh generation; proptest still replays every seed in
150+
// `lexer_panic.regressions` before checking the case count, so this
151+
// acts as a regression test for known panics.
152+
#![proptest_config(persistence_config(0, 0))]
153+
154+
#[test]
155+
fn known_regressions_do_not_panic(source in arb_source()) {
156+
run_with_timeout(source);
157+
}
158+
}
159+
160+
proptest! {
161+
#![proptest_config(persistence_config(1024, 4096))]
162+
163+
#[test]
164+
#[ignore = "fuzz test; opt-in via `cargo test ... -- --ignored`"]
165+
fn fuzz_random_strings(source in arb_source()) {
166+
run_with_timeout(source);
167+
}
168+
}

‎tests/proptest/tests/panic.regressions‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
# It is recommended to check this file in to source control so that
66
# everyone who runs the test benefits from these saved cases.
77
cc 50438352354e375bdee77e3a649ee19200ed77839b009f4ab76f4a782807be98 # shrinks to program = [2] a not
8+
cc cbd3b7e7a5fd5a359daf75241e22e7853af918aaee0624f0c40acc9581e8cd23 # shrinks to program = [3] -2 ^ 170141183460469231731687303715884105727

‎tests/proptest/tests/panic.rs‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,17 @@
2727
//! will guard against the regression automatically.
2828
2929
use ndc_analyser::{Analyser, ScopeTree};
30+
use ndc_core::FunctionRegistry;
3031
use ndc_lexer::{Span, Token, TokenLocation};
3132
use ndc_parser::Parser;
3233
use ndc_vm::compiler::Compiler;
33-
use ndc_vm::{OutputSink, Vm};
34+
use ndc_vm::value::{Function as VmFunction, Object as VmObject};
35+
use ndc_vm::{NativeFunction, OutputSink, Value as VmValue, Vm};
3436
use proptest::prelude::*;
3537
use proptest::test_runner::FileFailurePersistence;
3638
use std::fmt;
3739
use std::panic::{self, AssertUnwindSafe};
40+
use std::rc::Rc;
3841
use std::sync::Once;
3942
use std::sync::mpsc;
4043
use std::thread;
@@ -207,17 +210,34 @@ fn arb_program() -> impl Strategy<Value = Program> {
207210
prop::collection::vec(arb_token().prop_map(loc), 0..=MAX_PROGRAM_LEN).prop_map(Program)
208211
}
209212

210-
/// Drive parser → analyser → compiler → VM. Returns `()` on any error
211-
/// because the test only cares about panics, not error variants. The
212-
/// analyser runs against an empty global scope (no stdlib registered);
213-
/// undefined-identifier failures are expected and silently ignored.
213+
/// Build a fresh stdlib `FunctionRegistry`. Cannot be cached across worker
214+
/// threads because `Rc<NativeFunction>` is `!Send`, so we rebuild it once
215+
/// per case. In release mode this is sub-millisecond; in debug it adds
216+
/// noticeable overhead but the regression-replay test only triggers it
217+
/// for failing seeds, and the fuzz test is opt-in.
218+
fn build_stdlib_registry() -> FunctionRegistry<Rc<NativeFunction>> {
219+
let mut registry = FunctionRegistry::default();
220+
ndc_stdlib::register(&mut registry);
221+
registry
222+
}
223+
224+
/// Drive parser → analyser → compiler → VM with the stdlib registered, so
225+
/// the analyser accepts programs that reference built-ins like `print`,
226+
/// `len`, `sum`, etc. Returns `()` on any error — the test only cares
227+
/// about panics, not error variants.
214228
fn run_pipeline(tokens: Vec<TokenLocation>) {
215229
let mut expressions = match Parser::from_tokens(tokens).parse() {
216230
Ok(e) => e,
217231
Err(_) => return,
218232
};
219233

220-
let mut analyser = Analyser::from_scope_tree(ScopeTree::from_global_scope(vec![]));
234+
let registry = build_stdlib_registry();
235+
let scope = registry
236+
.iter()
237+
.map(|f| (f.name.clone(), f.static_type.clone()))
238+
.collect();
239+
240+
let mut analyser = Analyser::from_scope_tree(ScopeTree::from_global_scope(scope));
221241
for e in &mut expressions {
222242
if analyser.analyse(e).is_err() {
223243
return;
@@ -232,7 +252,18 @@ fn run_pipeline(tokens: Vec<TokenLocation>) {
232252
Err(_) => return,
233253
};
234254

235-
let mut vm = Vm::new(compiled, Vec::new()).with_output(OutputSink::Buffer(Vec::new()));
255+
// Globals must be in the same iteration order as the analyser scope
256+
// so the global slot indices match.
257+
let globals: Vec<VmValue> = registry
258+
.iter()
259+
.map(|native| {
260+
VmValue::Object(Rc::new(VmObject::Function(VmFunction::Native(Rc::clone(
261+
native,
262+
)))))
263+
})
264+
.collect();
265+
266+
let mut vm = Vm::new(compiled, globals).with_output(OutputSink::Buffer(Vec::new()));
236267
let _ = vm.run();
237268
}
238269

0 commit comments

Comments
 (0)