Skip to content

RQ-71-STACKDEPTH (#1341): report a per-export maximum native stack depth — the number a --relocatable embedder has never had - #1351

Merged
avrabe merged 3 commits into
mainfrom
feat/stackdepth-1341
Sep 23, 2026
Merged

avrabe merged 3 commits into
mainfrom
feat/stackdepth-1341

Conversation

@avrabe

@avrabe avrabe commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Gives a --relocatable embedder the one number the object has never carried: how much native stack a call into an export can consume.

The failure this is about

cpetig's export returned an answer bit-identical to wasmtime, every time, and then corrupted an unrelated RTOS pointer. It overran its NuttX task stack while the linear-memory guard bands above and below stayed untouched. A --relocatable object tells the embedder how much linear memory to reserve and nothing at all about stack, so the region gets guessed — and guessing low fails silently, with a plausible result.

The reporter's proposed mechanism was unsound as stated

"If --emit-wcet already walks the call graph, the same traversal would yield the depth."

The traversal is right — memoized DFS, cycle detection, honest declines. The arithmetic is not. wcet_compose weights each callee by its proven execution count, so a callee invoked 1000× inside a loop would be charged 1000× the stack it uses. Stack depth is a MAX over the call tree, never a trip-weighted sum. And over-reporting isn't the safe direction: an embedder told 2 MB for a 2.3 KB requirement stops believing the number.

The decline set isn't reusable either. WcetDecline carries Loop, LoopedExpansion, UnmodeledOp, UnresolvedBranch — none affects stack depth, because a loop body reuses one frame every trip. A data-dependent loop is WCET-unbounded and perfectly stack-bounded. Riding on it would have refused a bound for most real modules, the reporter's control loop included. So this is a sibling pass sharing only the DFS shape.

Three ways to count, measured on a committed fixture

stack_depth_branch_1341.wat — top calls a and b; both call leaf:

method bytes vs truth
(a) the reporter's sum(sub sp) 96 under by 24 (20%) — the dangerous direction
(b) naive sum of every frame 176 over by 46%
(c) truth: max over root-to-leaf paths 120 —

(a) misses three things. The prologue moves SP in up to three steps — push.w {r4-r8,lr} (24 B, always), vpush {d8-d15} (64 B, conditional), sub sp — and sub sp is emitted only when frame_size > 0, so leaf consumes 8 B while being invisible to that grep. The immediate is also hex: #0x20 is 32, and a #[0-9]+ regex reads it as 0. That one isn't hypothetical — the first measuring command written for this lane had exactly that bug and printed "sum: 0".

The figure depends on the link mode, and that's legitimate

The oracle's first version asserted --relocatable and self-contained builds report the same number. They don't: 8/64/64/120 vs 16/72/72/128 on one fixture. --relocatable forces the direct selector (#197); the optimized prologue costs 8 more bytes per frame. The number belongs to the build you ship, so the oracle now asserts the two paths agree on which functions are boundable and lets the figures differ.

Evidence — and the oracle found two of its author's errors

  • scripts/repro/stack_depth_1341.py, CI-wired, 6 emulations, proven red-first against the pre-feature binary (rc=1, "unexpected argument --emit-stack-depth").
  • Both bounded fixtures: reported 128 B vs measured peak 128 B — slack zero. The bound isn't merely sound, it's exact.
  • call_indirect was declining with the wrong reason. It reaches the stream as ldr rN,[pc,#..] + blx rN, not a CallIndirect pseudo-op, so the catch-all called it unknown-sp-move — safe, and wrong: that tells an embedder the walker got confused rather than that their call graph is unresolvable.
  • The oracle also demanded too much: it required that nothing in an unboundable module be bounded, when that module's leaves are perfectly boundable and only the export matters. Both corrections are written into the script, not quietly applied.
  • 7 synth-backend unit tests, including composition_takes_the_max_over_branches_not_the_sum — the one that catches a regression to the WCET composer's arithmetic.

What is NOT claimed

#1341 asked for two things and this ships one. The depth is reported (sidecar + compile line); it is not recorded in the object as an ELF note, so a build step must read a file beside the .o rather than the .o itself. That's weaker than asked, and it's stated as the follow-up it is. The documentation half was already met before this work — docs/embedder-abi-relocatable-arm.md already said the embedder owns stack placement and size; what was missing was a number.

The reporter's own module is an issue attachment, so their 1984/4032 B bracketing stays [REPORTER-ONLY] and is not restated as re-derived.

Housekeeping done deliberately

  • Mutant ledger re-anchored proactively (the v0.70 lesson, applied instead of rediscovered): this diff inserts lines in arm_backend.rs above five pinned sites. reanchor moved 14 sites by exactly +8, 0 not found, mutants/controls/ci_subset/UNTESTED all unchanged — re-located, never re-baselined.
  • The unicorn job's --min-oracles floor moves 6 → 7. It now runs 7 oracles; leaving the floor would let this one be deleted silently — the RQ-70-WINDOWVAC class.

Verification

cargo fmt --check rc=0, clippy --workspace --all-targets -D warnings rc=0, cargo test --workspace 168 suites / 0 failures; claim_check 75/75, status_evidence / check_version_pins / oracle_wiring / artifact_citation all rc=0.

Refs #1341

🤖 Generated with Claude Code

https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L

…pth — the number a --relocatable embedder has never had

cpetig's export returned a bit-identical-to-wasmtime answer EVERY time and then
corrupted an unrelated RTOS pointer: it overran its NuttX task stack while the
linear-memory guard bands stayed untouched. A --relocatable object says how much
LINEAR MEMORY to reserve and nothing about native stack, so the region is
guessed, and guessing low fails SILENTLY with a plausible result.

`--emit-stack-depth` now writes a `synth-stack-v1` sidecar with a per-export
maximum, or a NAMED decline. The rule is `own + MAX over the call tree`.

THE REPORTER'S PROPOSED MECHANISM WAS UNSOUND AS STATED, which is what shaped
this. "If --emit-wcet already walks the call graph, the same traversal would
yield the depth" — the TRAVERSAL is right, the ARITHMETIC is not.
`wcet_compose` weights each callee by its PROVEN EXECUTION COUNT, so a callee
invoked 1000x in a loop would be charged 1000x the stack it uses. Stack depth
is a MAX over the call tree. And the WCET DECLINE SET is not reusable either:
`Loop`, `LoopedExpansion`, `UnmodeledOp`, `UnresolvedBranch` do not affect stack
depth, because a loop body reuses one frame every trip. Riding on it would have
refused a bound for most real modules, the reporter's control loop included.
So this is a SIBLING pass sharing only the DFS shape.

THE THREE WAYS TO COUNT, measured on the committed branch fixture:

  (a) the reporter's sum of `sub sp` only :  96 B  UNDER by 24 (20%)
  (b) a naive sum of every frame          : 176 B  OVER  by 56 (46%)
  (c) TRUTH: max over root-to-leaf paths  : 120 B

(a) is the dangerous direction and misses three things: the callee-saved `push`
(24 B, before any `sub sp`), the conditional `vpush {d8-d15}` (64 B), and every
function whose frame_size is 0 — which emits NO `sub sp` at all while still
consuming 8 bytes. The immediate is also HEX: `#0x20` is 32, and a `#[0-9]+`
regex reads it as 0. The first measuring command written for this lane had
exactly that bug and reported "sum: 0".

THE FIGURE DEPENDS ON THE LINK MODE, AND THAT IS LEGITIMATE. The oracle's first
version asserted --relocatable and self-contained agree. They do not:
8/64/64/120 vs 16/72/72/128 on one fixture, because --relocatable forces the
DIRECT selector (#197) and the optimized prologue costs 8 more bytes per frame.
The number belongs to the build you ship; the oracle now asserts the two paths
agree on WHICH functions are boundable and lets the figures differ.

EVIDENCE, and the oracle found two of its author's errors:
  - scripts/repro/stack_depth_1341.py, CI-wired, 6 emulations, RED-FIRST
    against the pre-feature binary ("unexpected argument --emit-stack-depth").
  - Both bounded fixtures: reported 128 B vs measured peak 128 B — slack ZERO.
  - `call_indirect` reaches the stream as `ldr rN,[pc,#..]` + `blx rN`, not as a
    CallIndirect pseudo-op, so the catch-all was declining it `unknown-sp-move`
    — safe and WRONG: that tells an embedder the walker got confused rather
    than that their call graph is unresolvable. The oracle demanded the
    accurate reason.
  - The oracle also wrongly required that NOTHING in an unboundable module be
    bounded; that module's leaves are perfectly boundable and only the EXPORT
    matters. Both corrections are recorded in the script, not quietly applied.
  - 7 synth-backend unit tests, incl.
    `composition_takes_the_max_over_branches_not_the_sum` — the one that catches
    a regression to the WCET composer's arithmetic.

NOT CLAIMED: #1341 asked for TWO things and this ships ONE. The depth is
REPORTED (sidecar + compile line); it is NOT recorded in the object as an ELF
note, so a build step must read a file beside the .o rather than the .o itself.
Weaker than asked, and stated as the follow-up it is. The documentation half was
already met before this work.

MUTANT LEDGER RE-ANCHORED, proactively this time (the v0.70 lesson): this diff
inserts lines in arm_backend.rs ABOVE five pinned sites. `reanchor` moved 14
sites by exactly +8, 0 not found, with mutants/controls/ci_subset/UNTESTED all
unchanged — re-located, never re-baselined.

The unicorn job's `--min-oracles` floor moves 6 -> 7: it now runs 7 oracles, and
leaving the floor would let this one be deleted silently.

cargo fmt rc=0, clippy --workspace --all-targets -D warnings rc=0, cargo test
--workspace 168 suites / 0 failures; claim_check 75/75, status_evidence +
check_version_pins + oracle_wiring + artifact_citation all rc=0.

Refs #1341

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
@codecov

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.68421% with 80 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/synth-core/src/stack_depth.rs 0.00% 38 Missing ⚠️
crates/synth-cli/src/main.rs 29.16% 34 Missing ⚠️
crates/synth-backend/src/stack_depth.rs 96.07% 8 Missing ⚠️

📢 Thoughts on this report? Let us know!

avrabe and others added 2 commits September 23, 2026 01:58
…7762 for the oracle this lane landed

CI reddened `Claim Check` on #1351 and the gate was right. `--exact-emulation-floor`
is EXACT, not a lower bound (RQ-63-FLOOREQ, v0.63) precisely so that an oracle
landing without its same-PR bump cannot re-open slack silently — the v0.63
measurement found a +50 drift that left the wiring gate, claim_check AND
status_evidence all at exit 0.

`scripts/repro/stack_depth_1341.py` declares `# ci-checks: emulations >= 3`, so
the derived total moves 367759 -> 367762. TWO places pin it and both had to
move together, which is the pair doing its job:

  .github/workflows/ci.yml:603  --exact-emulation-floor 367762
  claims.yaml:3756              text: "--exact-emulation-floor 367762"

Verified both directions rather than one: `oracle_wiring_check.py
--exact-emulation-floor 367762` exits 0 and `... 367759` exits 1, so the pin is
load-bearing and not decorative.

The other 367759 hits in the tree are HISTORICAL records of past releases
(CHANGELOG's v0.66 entry, the v0.66 cold review) and are deliberately untouched
— they describe the tree as it was, not as it is.

claim_check 75/75, status_evidence + check_version_pins + oracle_wiring +
artifact_citation all rc=0. No Rust changed.

Refs #1341

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
… three new fixtures — and ASSERT the attributed-instruction count that had been narrated and went stale

CI reddened the home-register write audit and the gate was right: the audit's
corpus includes `scripts/repro/*.wat`, and this lane commits THREE fixtures
there. Same bookkeeping RQ-70-ALIAS and RQ-70-NPA each got.

  functions audited: 7207 -> 7239
  homes watched:     8220 -> 8228

PROVEN CORPUS GROWTH, NOT A CODEGEN CHANGE, by measurement rather than by the
obvious argument. The stack-depth pass is a pure read of `final_instrs` and
emits nothing, so it "cannot" move bytes — but the #1321 note set the precedent
of proving it, so: holding the three new fixtures aside and re-running THIS
BINARY over main's corpus reproduces 7207/8220 EXACTLY.

AND THE ISOLATION RUN FOUND SOMETHING ELSE. It printed `attributed
instructions: 217089` where the #1321 comment says "the 217081 attributed
instructions are UNCHANGED". That figure is STALE and had been for two
releases: RQ-70-NPA added a fixture (+4 functions, +6 homes), which necessarily
added instructions, so 217081 could not still hold the moment NPA landed.

NOTHING CAUGHT IT BECAUSE NO GREP EVER READ IT. The step asserts functions,
homes, POTENCY, WIDTH-1226 and hits; the attributed count lived only in a
comment. That is precisely the "a ratchet fixes the number and cannot fix the
prose" shape recorded in v0.69's lessons, sitting inside the job whose whole
purpose is pinning printed totals EXACTLY (the #1112 pattern).

So it is now ASSERTED rather than narrated:

  grep -Eq '^attributed instructions: 217291 ' haa1189.txt

and the stale sentence is corrected to say "the then-current
attributed-instruction count" instead of quoting a number that had moved. The
two remaining `217081` occurrences are deliberate quotations inside the new
comment explaining the staleness.

Every one of the step's six assertions was replayed against the derived output
before committing; all six match.

Local note for whoever hits this next: the audit FAILS in a fresh worktree with
"found 0 top-level .wast, want 257" — `tests/spec-testsuite` is a submodule and
is not populated by `git worktree add`. `git submodule update --init --depth 1
tests/spec-testsuite` fixes it; the submodule pointer is unchanged and is not
part of this commit.

claim_check 75/75, status_evidence + check_version_pins + oracle_wiring +
artifact_citation all rc=0. No Rust changed.

Refs #1341
Refs #1189

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJK5LZZEkV5smCY1jKn18L
@avrabe
avrabe merged commit 2da2a19 into main Sep 23, 2026
74 checks passed
@avrabe
avrabe deleted the feat/stackdepth-1341 branch September 23, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant