Skip to content

Preserve the ErrorContext cause chain across a defer on the unwind path (B-611)#3903

Merged
antoniosarosi merged 1 commit into
canaryfrom
antonio/b-611
Jul 2, 2026
Merged

Preserve the ErrorContext cause chain across a defer on the unwind path (B-611)#3903
antoniosarosi merged 1 commit into
canaryfrom
antonio/b-611

Conversation

@antoniosarosi

@antoniosarosi antoniosarosi commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Bug

A non-throwing defer {} on the frame an error unwinds through silently wiped the propagating error's .cause chain.

Repro:

class A { m: string }
class B { m: string }
function low() -> string throws A { throw A { m: \"origin\" } }
function mid() -> string throws B { defer {}  low() catch (e, ctx) { A => throw B { m: \"wrap\" } } }
function root() -> string { mid() catch_all (e, ctx) { _ => match (ctx.root_cause().error) { let a: A => a.m, let b: B => `LOST:${b.m}`, _ => \"?\" } } }

low() throws A; mid() catches A and throws B (so B.cause == A). With the defer {} present:

  • root()'s ctx.root_cause() returned B (\"LOST:wrap\") instead of walking past B to A (\"origin\").
  • ctx.to_string() dropped the During handling of the above error, another error occurred section and the A frame.

Removing the defer {} made both correct — the tell that the defer was the culprit.

Root cause

Two coupled deficiencies conspired; the plan's suspected VM-only cause was only half of it.

  1. Loweringcrates/baml_compiler2_mir/src/lower.rs AstExpr::Throw (~5783). An expression-position throw inside a defer region (or a catch arm/base) was lowered as a static error_local = v; goto catch_context.unwind_target, bypassing the exception funnel entirely. The funnel (try_unwind_exception) is what computes the BEP-042 cause (find_cause_context) and materializes the destination handler's ErrorContext. So throw B never hit the funnel — its cause was never computed. (AstStmt::Throw at ~13393 already always funnels, which is why the sibling-defer case worked.) As a side effect, an expression throw directly in a catch base left the bound ctx unmaterialized and crashed on access.

  2. VMcrates/bex_vm/src/vm.rs try_unwind_exception (~3390). The defer landing pad re-raises the in-flight error via Rethrow, and the funnel unconditionally forced cause_context = Value::NULL for any rethrow (the exact gap the vm.rs comment named: "would need a value->context association we don't track"). Even once throw B funnels and computes B.cause = A, the pad's transparent re-raise then nulled it before it reached root.

Fix

  • Lowering: AstExpr::Throw now routes through the funnel (throw/rethrow) like AstStmt::Throw, instead of the static jump. The exception table reaches the same innermost handler that catch_context.unwind_target pointed at (its region covers this PC), so control flow is unchanged — and confirmed by zero codegen snapshot churn.
  • VM (the plan's Candidate 1): added thrown_value_causes: Vec<(Value, Value)>, recording the cause computed at each value's fresh throw site, keyed by the thrown value. On a rethrow the funnel reuses this instead of nulling. It mirrors the existing seen_throw_values precedent exactly — same GC collect_roots/forward_roots hooks (both key and cause are heap pointers) and finalize clear. The recorded value is always the superseded error, never the re-raised value's own context, so it can never self-link; a genuine rethrow that never superseded an error has no entry and keeps cause = null.

Both are required: (1) gets B's cause computed/recorded; (2) preserves it across the pad's re-raise.

Candidate choice

I evaluated Candidate 1 (VM value→cause map) vs Candidate 2b (a dedicated RethrowTransparent opcode). Candidate 1 is used for the VM half — it is safe here (no self-link risk, clean GC rooting via the seen_throw_values pattern, null-cause preserved for genuine rethrows). But investigation against the real code showed Candidate 1 alone is insufficient: the throw in the repro is an expression throw that takes a static goto and never reaches the funnel, so there is nothing for the VM to record. The minimal correct addition is the one-branch lowering change so the throw funnels — which also has zero snapshot blast radius (unlike 2b, which would churn codegen for every defer fixture). So: Candidate 1 for the VM, plus the funnel-routing lowering fix.

Tests

New e2e tests in crates/baml_tests/tests/errorcontext.rs:

  • defer_on_unwind_preserves_cause_chain — the repro; root_cause().error == \"origin\".
  • defer_on_unwind_preserves_to_string_chainto_string regains the During handling of... section plus both frames.
  • defer_with_body_preserves_cause_chain — a non-empty, non-throwing defer body (isolates the transparent re-raise from empty-block elimination).
  • nested_defers_nonthrowing_preserve_cause — two stacked non-throwing defers.
  • defer_that_throws_still_chains_through_wrap — negative test: a defer whose body itself throws still builds the full chain cleanup -> wrap -> origin (the pre-existing wrap -> origin link is not dropped).

The existing guard tests are unchanged and still pass: no_match_rethrow_does_not_self_chain, throw_after_catch_in_layout_gap_does_not_chain, sibling_defers_that_throw_chain_to_root_cause, hazard_a, hazard_b.

Before / after (repro root())

  • Before: \"LOST:wrap\"
  • After: \"origin\" (removing the defer {} still yields \"origin\", unchanged)

to_string after the fix regains ... origin ... During handling of the above error, another error occurred: ... wrap.

Validation (all local, green)

  • cargo nextest run -p bex_vm -p bex_engine — 565 passed.
  • cargo test -p baml_tests -- --skip parser_stress — green (1724 snapshot + 1987 nextest + all integration groups; 0 failed; no snapshot churn).
  • cargo nextest run --all-features -E 'not package(baml_tests) and not package(/^sdk_test_/) and not binary(=pack_e2e)' — 4137 passed.
  • cargo clippy -p bex_vm -p baml_compiler2_mir — clean.

https://linear.app/boundaryml2/issue/B-611

Summary by CodeRabbit

  • Bug Fixes
    • Improved error reporting so chained causes are preserved during cleanup and re-throws.
    • Fixed cases where defer blocks during unwinding could previously hide earlier errors in the final message.
    • Error context output now keeps the full chain and ordering, including nested cleanup failures.

…th (B-611)

A non-throwing `defer` on the frame an error unwinds through wiped the
propagating error's `.cause` chain: `low()` throws A; `mid()` catches A and
throws B (so B.cause == A); with a `defer {}` armed in `mid`, `root()`'s
`ctx.root_cause()` returned B instead of walking past B to A, and
`ctx.to_string()` dropped the "During handling of the above error" section.

Two coupled deficiencies:

1. An expression-position `throw` inside a `defer` region (or a `catch`
   arm/base) was lowered as a static `error_local = v; goto pad` via
   `catch_context.unwind_target`, bypassing the exception funnel entirely.
   The funnel is what computes the BEP-042 cause (`find_cause_context`) and
   materializes the destination handler's `ErrorContext`, so the static jump
   dropped the cause (and left a bound `ctx` unmaterialized — an expression
   `throw` directly in a `catch` base previously crashed reading `ctx`).
   `AstExpr::Throw` now routes through the funnel like `AstStmt::Throw`
   already does; the exception table reaches the same innermost handler, so
   control flow is unchanged (no codegen snapshot churn).

2. The defer landing pad re-raises the in-flight error via `Rethrow`, and
   `try_unwind_exception` unconditionally nulled the cause for any rethrow.
   The VM now records the cause computed at each value's fresh throw site
   (`thrown_value_causes`, keyed by the thrown value, mirroring
   `seen_throw_values` with the same GC root/forward hooks and `finalize`
   clear) and reuses it on a rethrow instead of nulling. The recorded value
   is the superseded error, never the re-raised value's own context, so this
   can never self-link; a genuine rethrow that never superseded an error has
   no recorded entry and keeps its `cause = null`.

Together these preserve the chain across the pad's transparent re-raise
(root_cause reaches A, to_string regains the chain) while the existing
null-cause guards (no-match fall-through, layout-gap throws) still hold.

Adds e2e tests in errorcontext.rs: the repro (root_cause and to_string),
a non-empty non-throwing defer body, stacked non-throwing defers, and a
defer that itself throws (chain cleanup -> wrap -> origin stays intact).
@linear

linear Bot commented Jul 1, 2026

Copy link
Copy Markdown

B-611

@vercel

vercel Bot commented Jul 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Jul 1, 2026 11:52pm
promptfiddle Ready Ready Preview, Comment Jul 1, 2026 11:52pm
promptfiddle2 Ready Ready Preview, Comment Jul 1, 2026 11:52pm

Request Review

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a014d151-b85e-4952-bc2e-fee34dd4a4b5

📥 Commits

Reviewing files that changed from the base of the PR and between cfafa66 and dbf1672.

📒 Files selected for processing (3)
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_tests/tests/errorcontext.rs
  • baml_language/crates/bex_vm/src/vm.rs

📝 Walkthrough

Walkthrough

Throw expression lowering in the MIR compiler is unified to always use the exception funnel rather than a catch-context-specific path. The bex_vm now tracks a thrown_value_causes map to preserve and reuse computed cause contexts across transparent re-raises, with corresponding GC root updates. Regression tests validate cause-chain preservation across defers.

Changes

Cause Chain Preservation

Layer / File(s) Summary
Unify throw lowering
baml_language/crates/baml_compiler2_mir/src/lower.rs
AstExpr::Throw now always emits builder.throw/builder.rethrow through the exception funnel instead of writing to catch_context.error_local and using a direct goto.
VM cause tracking and unwinding
baml_language/crates/bex_vm/src/vm.rs
BexVm adds a thrown_value_causes vector recording (thrown value, cause) pairs, initialized at construction and cleared in finalize(); try_unwind_exception reuses recorded causes for rethrows and records new causes for fresh throws; GC root collection/forwarding include both elements of each pair.
B-611 regression tests
baml_language/crates/baml_tests/tests/errorcontext.rs
Five new tests verify non-throwing defers, non-empty defers, stacked defers, and throwing defers during unwind preserve the original cause chain and rendered ctx.to_string() output.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • BoundaryML/baml#3316: Both touch try_unwind_exception throw/rethrow/catch handling in bex_vm/src/vm.rs.
  • BoundaryML/baml#3785: Both touch the exception-unwind/throw dispatch path and defer execution during unwind.
  • BoundaryML/baml#3886: Both modify BEP-042 cause-chain construction inside try_unwind_exception.

Poem

A rabbit hops through unwind's maze,
Chasing causes through the fog and haze,
No more nulled chains, no more lost trace,
Each rethrow keeps its rightful place,
Hop, hop, hooray — the errors chain in grace! 🐇🔗

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main fix: preserving the ErrorContext cause chain across defer-based unwinding.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch antonio/b-611

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Binary size checks passed

7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
baml-cli Linux 🔒 21.6 MB 9.2 MB file 21.5 MB +117.5 KB (+0.5%) OK
packed-program Linux 🔒 15.8 MB 6.6 MB file 15.6 MB +148.6 KB (+1.0%) OK
baml-cli macOS 🔒 16.6 MB 8.0 MB file 16.6 MB +66.3 KB (+0.4%) OK
packed-program macOS 🔒 12.2 MB 5.8 MB file 12.1 MB +83.1 KB (+0.7%) OK
baml-cli Windows 🔒 18.2 MB 8.2 MB file 18.1 MB +108.0 KB (+0.6%) OK
packed-program Windows 🔒 13.1 MB 5.9 MB file 13.0 MB +130.2 KB (+1.0%) OK
bridge_wasm WASM 14.5 MB 🔒 4.1 MB gzip 4.1 MB +45.2 KB (+1.1%) OK

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.


Generated by cargo size-gate · workflow run

@antoniosarosi
antoniosarosi added this pull request to the merge queue Jul 2, 2026
Merged via the queue into canary with commit 5ba654d Jul 2, 2026
52 of 53 checks passed
@antoniosarosi
antoniosarosi deleted the antonio/b-611 branch July 2, 2026 00:37
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