fix(quickcheck): produce shrink candidates without recursing per block - #4211
Conversation
There was a problem hiding this comment.
🟢 Approval recommended
The functional change is well-scoped, preserves existing candidate behavior (pinned by tests), and includes targeted regression coverage for the reported stack overflow.
Pull request overview
Fixes stack overflows on js/wasm backends when shrinking large Arrays/Lists by changing shrink-candidate generation to an iterative cursor-based approach (so iterator construction is O(1) and no longer recurses per removed block), while preserving the exact candidate sequence and size_hint behavior.
Changes:
- Rework
removes_array/removes_listto generate candidates iteratively instead of via recursive iterator construction. - Add whitebox tests that (a) pin the exact candidate sequences and
size_hints for many(n, k)pairs and (b) regress the deep-recursion stack overflow scenario. - Restore collection-shrink benchmark inputs to 10,000 elements now that shrinking no longer overflows the stack.
File summaries
| File | Description |
|---|---|
| quickcheck/shrink/utils.mbt | Makes removes_array and removes_list cursor-iterative to avoid deep recursion during iterator construction. |
| quickcheck/shrink/removes_wbtest.mbt | Characterizes and pins candidate ordering + size_hint for removes_array/removes_list across many sizes. |
| quickcheck/shrink/deep_recursion_wbtest.mbt | Regression tests to ensure large collection shrinking yields candidates without stack overflow on js/wasm. |
| quickcheck/shrink_collection_bench_test.mbt | Increases benchmark input size back to 10,000 elements. |
Review details
Suppressed comments (2)
quickcheck/shrink/deep_recursion_wbtest.mbt:39
- This comment also describes the pre-fix recursive behavior in present tense. Updating it to past tense (and noting it’s guarding against the prior failure mode) will keep the test documentation accurate.
/// `removes_array` has the same shape. Its candidate order reaches `k = 1`
/// only at the end of the sequence, so a property test hits this later than it
/// hits the `List` case, but the recursion depth is the same.
quickcheck/shrink/deep_recursion_wbtest.mbt:52
- The test description says shrinking a large list "crashes" while shrinking; after this PR the expected behavior is that it yields a candidate. Rephrasing to describe the historical symptom (as a regression test) avoids confusing readers.
/// The user-visible symptom: a property test whose counterexample is a large
/// list crashes while shrinking instead of reporting the counterexample.
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// `removes_list` recurses once per removed block, and the recursive call sits | ||
| /// in argument position, so the whole descent runs while the iterator is being | ||
| /// built -- before it can yield anything. With `k = 1` that is one frame per | ||
| /// element. |
Coverage Report for CI Build 6582Coverage remained the same at 89.27%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
ff70d6c to
bf18321
Compare
|
Addressed the Copilot review: the three doc comments in Re-validated: |
…he stack Two test files, both against the current implementations. `removes_wbtest.mbt` pins what `removes_array` and `removes_list` produce — one candidate per block offset that still leaves a whole block, ascending for the array and descending for the list — along with their `size_hint`, for every `(n, k)` pair up to 24 elements. It passes as things stand, so a later change to how the candidates are produced has to keep producing the same ones. `deep_recursion_wbtest.mbt` shows the bug. Both functions recurse once per removed block, and the recursive call sits in argument position, so the entire descent runs while the iterator is being built — before it can yield anything. With `k = 1` that is one stack frame per element. `Shrink for @list.List` reaches `k = 1` first (it iterates `[n/2, .., 1]` with `rev_iter`), so shrinking a large list crashes on the first candidate: a property test whose counterexample is big reports a stack overflow instead of the counterexample. `Shrink for Array` iterates the same list forward, so it reaches `k = 1` only at the end of the candidate sequence — same depth, later. Those three tests fail on js, wasm and wasm-gc (`RangeError: Maximum call stack size exceeded`, frames at `quickcheck/shrink/utils.mbt:49`) and pass on native, whose stack holds 20,000 frames. The next commit fixes them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jtBJHK749cpStav5ZMNCr
Replace the recursion in `removes_array` and `removes_list` with a cursor over the block offsets. Both now walk `0, k, 2k, ..` directly — ascending for the array, descending for the list — so building the iterator is O(1) and yielding a candidate uses no stack beyond the call itself. The candidate sequences and `size_hint`s are the ones the previous commit pinned, which is why that test file is unchanged here. This fixes the three stack-overflow tests from the previous commit on js, wasm and wasm-gc, so a property test whose counterexample is a large collection now shrinks instead of dying with `RangeError: Maximum call stack size exceeded`. It is also much faster on the `List` side, because the old form rebuilt the candidate through one `concat` per level on the way back up, where the new one does a single `take`/`drop`/`concat`. First candidate from a 10,000-element collection, native release: | Collection | Before | After | | ---------- | --------: | --------: | | Array | 240.69 ns | 288.55 ns | | List | 1.32 ms | 213.65 µs | The array case is a hair slower — it now allocates the candidate through an array spread instead of returning a shared empty literal — and in exchange the `k == n` special case added in #4208 is gone, since taking a zero-length prefix already costs nothing. The benchmark goes back to 10,000 elements; #4208 had to lower it to 1,000 precisely because of the bug this fixes. The comments in `deep_recursion_wbtest.mbt` move to the past tense here, so that they describe the failure the tests guard against rather than the code as it now stands. Reviewed by Codex CLI together with the preceding test commit: "Verified ordering, exact size hints, non-divisible lengths, `k == n` without element copying, and cursor compatibility with single-use iterators. Independent old/new models agree for 5,050 input pairs." Signed-off-by: Codex CLI <codex@openai.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jtBJHK749cpStav5ZMNCr
bf18321 to
9e654ed
Compare
Shrinking a large collection crashes with a stack overflow on js, wasm and wasm-gc: a property test whose counterexample is big reports
RangeError: Maximum call stack size exceededinstead of the counterexample.Two commits, deliberately in this order.
6d03c2ae— tests against the code as it standsremoves_wbtest.mbtpins whatremoves_arrayandremoves_listproduce — one candidate per block offset that still leaves a whole block, ascending for the array and descending for the list — together with theirsize_hint, for every(n, k)withn <= 24and1 <= k <= n. It passes on this commit, before any production code changes, so it is a characterisation of the existing behaviour rather than of the fix.deep_recursion_wbtest.mbtshows the bug, and its three tests fail on this commit on js, wasm and wasm-gc (frames atquickcheck/shrink/utils.mbt:49) while passing on native.ff70d6ce— the fixBoth functions recursed once per removed block, with the recursive call in argument position — so the whole descent ran while the iterator was being built, before it could yield anything. With
k = 1that is one stack frame per element.Shrink for @list.Listasks fork = 1first (it iterates[n/2, .., 1]throughrev_iter), so a list crashes on its very first candidate;Shrink for Arrayiterates the same list forward and reachesk = 1only at the end of the sequence — same depth, later.They now walk the offsets with a cursor instead:
Building the iterator is O(1) and yielding a candidate uses no stack beyond the call.
removes_wbtest.mbtis untouched by this commit, which is the evidence that the sequences and hints did not move.Where this comes from
removes_array/removes_listarrived with #3955, transliterated from Haskell QuickCheck'sshrinkList, where the shape is fine because the recursive call is a thunk and only the consumed elements get forced. In a strict language the same expression means "recurse to the bottom before returning anything".shr_sub_termsin the same package has the same origin; #4208 deferred its construction, and making it iterative needs a different fix — out of scope here.Numbers
Faster too, because the old form rebuilt each candidate through one
concatper level on the way back up where the new one does a singletake/drop/concat. First candidate from a 10,000-element collection, native release:The array case is a hair slower — it allocates the candidate through an array spread rather than returning a shared empty literal — and in exchange the
k == nspecial case added in #4208 is gone, since taking a zero-length prefix already costs nothing. The benchmark also goes back to 10,000 elements; #4208 had to lower it to 1,000 precisely because of this bug.Validation
moon test -p quickcheck/shrink --target all59/59 andmoon test -p quickcheck --target all163/163 on wasm, wasm-gc, js and native. On6d03c2aethe first command gives 56/59 on the three non-native backends, failing exactly the three deep-recursion tests.moon check --target all,moon fmt --checkandmoon infoclean; no.mbtidiff (both functions are private).moon bench -p quickcheckpasses on all four targets at 10,000 elements.removes_wbtest.mbt:52;size_hint=(n - k) / k + 2failsremoves_wbtest.mbt:30.(n, k)pairs throughn = 100.🤖 Generated with Claude Code
https://claude.ai/code/session_016jtBJHK749cpStav5ZMNCr