Skip to content

fix(wire): bound recursive Serialize, de-recurse Drop (#1056)#1082

Merged
dekobon merged 5 commits into
mainfrom
fix/1056-recursive-serialize-drop
Jul 26, 2026
Merged

fix(wire): bound recursive Serialize, de-recurse Drop (#1056)#1082
dekobon merged 5 commits into
mainfrom
fix/1056-recursive-serialize-drop

Conversation

@dekobon

@dekobon dekobon commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Closes #1056.

The bug

bca metrics -O json on 1 000 nested functions — 11 KB of source
overflowed the thread stack. That is a SIGABRT, not a catchable panic:
bca-web's spawn_blocking wrapper turns a panic into one failed
request, but an abort takes the whole process down with every request in
flight. The 4 MiB body cap does not help when the payload is kilobytes.

Issues #700 / #709 converted every AST traversal to an explicit work
stack. This is the same hazard in the recursive types, which those
issues' three small-stack regression tests never reached — all three
exercise the dump walk and build their fixtures by hand.

What actually overflowed

The issue was filed against Serialize, supported by a clean-looking
bisection (bca check builds and drops the identical tree without
serializing it, and survived). Re-measuring each stage in isolation on a
default 2 MiB thread told a different story:

stage release debug
wire::FuncSpace::from (recursive From) ~900 ~380
Serialize → JSON 8 192–16 384 1 024–2 048
Serialize → YAML 4 096–8 192 1 024–2 048
Serialize → TOML 3 072–4 096 384–512
Serialize → CBOR 8 192–16 384 768–1 024
derived Drop 32 768–65 536 8 192–16 384

The reported abort was the From conversion, not Serialize — it
runs to completion before serialization starts, at ~2.3 KB per frame
against the JSON serializer's ~170 B. Serialize and Drop are
reachable too, just needing deeper input: ~380 000 nested fns fit
inside the 4 MiB body cap.

The issue's scope-correction note also wrote Drop off after a
10 000-level chain survived. A cheap frame is not an iterative one — it
aborts at 16 000 in a debug build.

A fourth site the issue did not list: AstNode

/ast is worse than /metrics, because AST depth is far cheaper to buy
than space depth. 40 000 levels of nested parentheses — an 80 KB
payload — aborted the process
in serde_json::to_string on a release
build. The dump walk itself survived (that is #700's work); the
overflow is the derived Serialize on AstNode, and then again its
Drop.

The fix

  • From is iterative. wire::FuncSpace::from and wire::Ops::from
    share a map_tree helper driving an explicit work stack. Verified
    converting a 1 000 000-level chain on a 512 KiB thread.
  • Serialize is bounded, not de-recursed. serde offers no
    iterative escape — serialize_field must run the child's Serialize
    to completion before it returns — so the depth is capped and a deeper
    tree fails with an ordinary serializer error naming the type and the
    limit. New public constants wire::MAX_SPACE_SERIALIZE_DEPTH (128) and
    MAX_AST_SERIALIZE_DEPTH (512). The space limit mirrors the 128-level
    recursion limit serde_json's Deserializer already applies to the
    same documents.
  • Drop is iterative on FuncSpace, Ops, AstNode, and the two
    wire mirrors. Verified at 1 000 000 levels.

Picking the limits

Grounded against the corpus under tests/repositories (14 450 files —
TensorFlow, DeepSpeech, serde, …): deepest AST 188 levels, deepest
FuncSpace nesting 10. The limits are ~2.7x and ~13x those. On the
read side a FuncSpace document cannot be parsed back past ~61 levels
anyway (two JSON nesting levels per space against serde_json's 128), so
the emit limit is the more permissive of the two.

⚠️ This carries a deliberate SemVer exception

Adding impl Drop forbids moving fields out by value:
let m = space.metrics; is now E0509, fixed with a .clone() or a
borrow. 13 sites inside this repository needed it.

STABILITY.md reserves source-level breaks for a major bump. This one
lands under a minor as an explicit, documented exception — recorded
under (breaking) in CHANGELOG.md and in STABILITY.md — because
the alternative was leaving a remotely-reachable process abort open until
3.0. There is no way to break the Drop chain without an impl Drop
on the type. Approved by the maintainer before landing; please confirm
you are still happy with that call.

The workspace moves to 2.1.0 accordingly.

Verification

Seven regression tests, each confirmed to fail against the pre-fix
code
by perturbing production back to its former shape — five abort the
test binary with SIGABRT, exactly the reported symptom. Per the note in
the issue, they drive analyze / build FuncSpace chains rather than
reusing the dump fixtures, so they scale with space nesting rather than
AST depth.

CLI, on the original reproducer:

$ python3 -c "n=1000; open('/tmp/nf.rs','w').write(''.join('fn f%d(){'%i for i in range(n)) + '}'*n)"
$ bca metrics --no-config --metrics sloc -p /tmp/nf.rs -O json
error processing /tmp/nf.rs: FuncSpace nesting is deeper than the serialization limit of 128 levels

Live bca-web, both hostile payloads, same process:

POST /v1/ast     30 000 nested parens  -> 500 {"error_kind":"serialize_failed"}
POST /v1/metrics  5 000 nested fns     -> 500 {"error_kind":"serialize_failed"}
POST /v1/ast     shallow               -> 200
POST /v1/metrics shallow               -> 200
GET  /v1/ping                          -> 200   (process still up)

make pre-commit green.

Commits

3fd01c70 the fix, tests, CHANGELOG / STABILITY / book updates
7b8f3fd6 version bump to 2.1.0 (lockfiles, man pages, grammar literals, SARIF snapshots)
8634dd5c review follow-up: the tests cost ~78 s from the quadratic Node::parent walks still tracked in #1062 at depth 8 000. Analysed fixtures dropped to 2 000, a hand-built 100 000-level chain took over the teardown coverage — 2.2 s now, and pins Drop 25x deeper
d827ee7b removes three now-obsolete workarounds (two flatten-before-drop loops, one mem::forget leak) whose comments asserted something false; those tests now cover the iterative Drop for free
e8841073 lesson 81

Known remaining recursion

Clone, PartialEq, and Debug are still recursive on these types
(Clone aborts around 700 levels). None is on a production path — the
CLI, web, and Python crates all borrow, and nothing compares or
{:?}-formats a whole tree — so none is reachable today, and
hand-writing them would trade a live-bug fix for a field-drift hazard the
derives currently rule out. Worth its own issue if a tree-cloning or
tree-comparing API is ever added.

dekobon added 5 commits July 25, 2026 23:26
`bca metrics -O json` on ~1 000 nested functions (11 KB of source)
overflowed the thread stack. A stack overflow is a `SIGABRT`, not a
catchable panic: `bca-web`'s `spawn_blocking` wrapper turns a panic
into one failed request, but an abort takes the whole process down
with every request in flight.

Issues #700 / #709 converted every AST *traversal* to an explicit work
stack; this is the same hazard in the recursive *types*, which those
tests never reached. Three recursions were involved:

- `wire::FuncSpace::from` / `wire::Ops::from` projected a nested tree
  with `spaces.iter().map(Self::from).collect()` — one frame per level
  at roughly 2.3 KB each, aborting at ~900 levels on a 2 MiB thread.
  Both now walk an explicit work stack.
- The derived `Serialize` on `FuncSpace`, `Ops`, and `AstNode`. serde
  cannot emit a tree without one native frame per level, so the depth
  is bounded rather than de-recursed: 128 nested spaces, 512 nested
  AST nodes, past which serialization fails with an ordinary error.
  This mirrors the 128-level limit serde_json's `Deserializer` already
  applies to the same documents.
- The compiler-generated `Drop` glue, which aborted at ~16 000 levels
  in a debug build. An explicit `Drop` hoists descendants into a flat
  work list, so teardown costs no stack depth.

The `Drop` impls are a source-level break — fields can no longer be
moved out of these types by value. STABILITY.md reserves such breaks
for a major bump; this one lands under a minor as a documented
exception, because the alternative was leaving a remotely-reachable
process abort open until 3.0.

Both limits sit far above real source: across the 14 450-file corpus
under tests/repositories the deepest AST is 188 levels and the deepest
space nesting is 10.

Fixes #1056
Carries the #1056 fix, whose `Drop` impls are a documented source-level
break, so the workspace moves to a minor rather than a patch bump.

Regenerates the lockfiles, the man pages, the vendored grammar version
literals in `src/langs.rs`, and the SARIF snapshots that embed the
driver version.
The #1056 regression tests drove `analyze` at depth 8 000, which costs
~25s each in a debug build — not from the code under test, but from the
quadratic `Node::parent` ancestor walks still tracked in #1062.

Analysed fixtures drop to depth 2 000 (still ~20x the depth at which the
recursive `From` overflowed a 512 KiB thread), and a hand-built 100 000-
level chain takes over the teardown coverage, which the 8 000-level
fixture only cleared by 2x. Seven tests now run in 2.2s instead of 78s,
and pin the `Drop` de-recursion 25x deeper than before.
Three tests stepped around the recursive `Drop` on `FuncSpace` / `Ops`
that #1056 removed: two flattened the chain by hand and one leaked it
with `std::mem::forget`. Their comments now assert something false, and
stepping around the teardown is strictly weaker than exercising it — the
tests cover the iterative `Drop` for free once the workaround is gone.

Lesson 47 prescribed those workarounds; annotated rather than rewritten,
since the advice still holds for recursive types without such a `Drop`.
#700 / #709 de-recursed every AST traversal but left the recursive types
alone; #1056 was the result. Records the audit that would have caught it,
and why the `Serialize` half can only be bounded, not de-recursed.
@dekobon dekobon added bug Something isn't working security Security-relevant finding output Report / serialization output surface labels Jul 26, 2026
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.06%. Comparing base (fda2039) to head (e884107).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1082      +/-   ##
==========================================
+ Coverage   98.04%   98.06%   +0.01%     
==========================================
  Files         267      268       +1     
  Lines       66363    66613     +250     
  Branches    65933    66183     +250     
==========================================
+ Hits        65063    65321     +258     
+ Misses        859      851       -8     
  Partials      441      441              
Flag Coverage Δ
python 100.00% <ø> (ø)
rust 98.04% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/ast.rs 97.44% <100.00%> (+3.85%) ⬆️
src/langs.rs 92.53% <ø> (ø)
src/metrics/cyclomatic.rs 99.93% <100.00%> (ø)
src/ops.rs 99.14% <ø> (ø)
src/output/dump_metrics.rs 82.79% <ø> (-0.32%) ⬇️
src/output/dump_ops.rs 79.83% <ø> (-0.66%) ⬇️
src/recursion.rs 100.00% <100.00%> (ø)
src/spaces.rs 100.00% <ø> (ø)
src/tools.rs 94.35% <100.00%> (+0.04%) ⬆️
src/wire.rs 99.62% <100.00%> (+0.17%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dekobon
dekobon merged commit 1b4eee3 into main Jul 26, 2026
49 checks passed
@dekobon
dekobon deleted the fix/1056-recursive-serialize-drop branch July 26, 2026 15:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working output Report / serialization output surface security Security-relevant finding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(wire): recursive Serialize/Drop on FuncSpace lack the #700 de-recursion

1 participant