Skip to content

Install the stack-exhaustion budget (EH-12..EH-15) - #153

Merged
biuld merged 1 commit into
masterfrom
slice/eh-budget
Oct 18, 2026
Merged

biuld merged 1 commit into
masterfrom
slice/eh-budget

Conversation

@biuld

@biuld biuld commented Oct 18, 2026

Copy link
Copy Markdown
Owner

What lands

Slice 4 of the exception-handling adoption (rows EH-12..EH-15), per the design from PR #149:

  • EH-12 — mir/budget.rs: W_f = 32 + 16 × locals (lowered count), STACK_BUDGET = 262144, recorded as Function::frame_weight at P9 from the lowered function's parameter+value count (the measured firing depth matches the recorded weight exactly: 262144/176 = 1490.0).
  • EH-13 — every lowered function gains the prologue (depth += W_f; if depth > STACK_BUDGET then throw :stack-exhaustion, out-of-function unwinding since no region is armed yet), epilogue at every Return, tail decrement at return_call*; trapping blocks emit none. Mutual even/odd tail recursion at depth 1,000,000 exits 0 — no false throw.
  • EH-14 — every try_table preceded by a watermark save into a fresh i32 local per region; catch path's first action restores. Leak fixture: 21,848 throw/catch iterations followed by shallow guarded rec(100) then deep guarded rec(100,000) — the deep recursion still reaches the budget point (no leak).
  • EH-15 — the synthesized run entry wraps main in try_table (catch_all) restoring depth then exit-with-code 1; coordination fixture (non-tail rec under the checkThrows discharge) yields the compiler's exception under the pinned engine, never the engine trap.

Measured calibration (first measured numbers of the adoption; recorded in the checklist + capability-profile notes)

  • Engine: wasmtime 48.0.1, CLI flag -W max-wasm-stack; pinned limit 524288 (measured equal to this engine's default — the pin is inert for the current corpus but makes the condition explicit). Driver run helpers now pin it.
  • Budget first fires at requested depth 1,490 (last non-firing 1,480); the engine's trap on the same function with the budget off sits between 6,000–7,000 frames — the budget fires at ~23% of the engine limit (≈4× headroom); depth-100 consumes ~7% of the budget.
  • Fixture teeth verified: raising STACK_BUDGET past the engine limit reproduces the recorded failure mode (engine call stack exhausted trap, exit 134).

Related invariant fixes (per the design's own authority)

  • Value availability and the structurer's predecessor/SCC analysis now use the unwind-edge CFG (handlers were unreachable on the normal CFG — the loop-of-guards shape P9 produces); a reducibility false positive on handler-into-loop back jumps is fixed. Prior fixtures preserved.
  • Slice-3 fixture assertions updated for the entry's try_table and watermark locals (intents preserved, commented).

Validation

cargo fmt --all --check ✅ · cargo clippy --workspace --all-targets -- -D warnings ✅ · full workspace suite ✅ 2189 passed, 0 failed (coordinator re-verified fmt/clippy + psrs-backend 587/0). Checklist rows EH-12..15 → Verified with measured evidence; D-04 untouched; profile default unchanged (the flip is EH-17).

The budget converts imminent native stack exhaustion into the compiler's
psrs:stack-exhaustion exception before the engine's trap, under the
watermark contract that keeps the depth counter honest across EH throws.

- mir/budget.rs: the weight formula W_f = FRAME_WEIGHT_FIXED +
  FRAME_WEIGHT_PER_LOCAL x locals with the design's provisional constants
  (32 / 16) and STACK_BUDGET = 262144; recorded on every MIR function as
  Function::frame_weight at P9, from the *lowered* function's parameter and
  value count (P9 adds fresh values beyond the CC source's).
- wasm/lower/budget.rs + function/mod.rs + structure/region/mod.rs: every
  lowered function gains the prologue (depth += W_f; if depth > STACK_BUDGET
  then throw, emitted before the first structured block — the overflow
  unwinds out of the function because no try region is armed yet), the
  epilogue decrement on every return path, and the tail decrement at every
  return_call* site; trapping blocks carry none.
- try_unit.rs: every try_table is preceded by the watermark save into the
  try region's fresh i32 local, and its catch path begins with the restore,
  settling every frame the throw unwound at once.
- wasm/lower/entry.rs: the synthesized run entry wraps its call to main in a
  try_table (catch_all); the handler restores depth to the value saved at
  entry and leaves through exit-with-code 1 (Entry gains declared locals,
  carried through verify and encode).
- The two CFG analyses that still viewed only normal edges are now truthful
  about unwind edges, per the design's invariant: value availability
  (mir/verify/function.rs) consumes dominators_with_unwind_edges so a
  handler can read enclosing values, and the structurer's predecessor map
  and SCC DFS (wasm/lower/structure/cfg) count unwind edges, so a handler
  joining a loop's body is no longer an irreducibility false positive.
- Coordination (EH-15): the execution helpers pin the engine's stack limit
  (-W max-wasm-stack=524288, measured equal to wasmtime 48.0.1's default);
  the coordination fixture (checkThrows discharge over a genuinely non-tail
  self-recursive function) yields the compiler's exception at depth ~1,490
  while the engine's trap sits between 6,000 and 7,000 frames, and the
  depth-100 returning recursion consumes ~7% of the budget. The leak fixture
  (21,848 throw/catch iterations then deep recursion) proves no counter
  leak; deep tail recursion at 1,000,000 still exits 0.
- The disabled profile emits none of the budget; no profile default change.
@biuld
biuld merged commit 9567cbb into master Oct 18, 2026
1 check passed
@biuld
biuld deleted the slice/eh-budget branch October 18, 2026 16:40
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