Repository navigation
feat(compiler2): add defer statement (BEP-042) - #3785
Conversation
Implements `defer { ... }`: a block scheduled to run at enclosing-block
exit, LIFO, on normal completion, `return`, `break`/`continue`, and
explicit `throw`. Block-scoped (per-iteration in loops); the body reads
live enclosing locals at exit (not a closure capture).
- lexer/parser/CST/AST: `defer` keyword, DEFER_STMT, `Stmt::Defer { body }`
lowered inline as a block.
- TIR: loop-aware escape validation — `return`/`break`/`continue` that
escape a defer is a compile error (E0139); a break/continue targeting a
loop inside the defer is allowed. `throw` is permitted and counts toward
the function's throws contract.
- MIR: a `defer_stack` parallel to `watched_locals_stack`, replayed (LIFO,
re-lowered inline) at every static scope exit; swapped at lambda
boundaries. No new opcode or VM change.
Scope: `defer` only — no cause chain, no `cleanup` finalizer. A defer that
throws mid-unwind uses existing replace-semantics.
Remaining (Stage 2): run defers when an exception propagates out of a call
(unwind landing pads via nested catch regions); diagnostic + snapshot tests.
Tests: baml_tests/tests/defer.rs (LIFO, block-scoping, per-iteration,
final-value capture) — passing.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughImplements the ChangesBEP-042: defer statement full pipeline
Sequence Diagram(s)sequenceDiagram
participant Source
participant Lexer
participant Parser
participant ASTLower as AST Lowering
participant TIR as TIR Type Checker
participant MIR as MIR Lowering
participant VM as VM Runtime
Source->>Lexer: defer { BODY }
Lexer->>Parser: TokenKind::Defer → KW_DEFER
Parser->>ASTLower: DEFER_STMT node
ASTLower->>TIR: Stmt::Defer { body: ExprId }
TIR->>TIR: push loop_depth floor, infer body
TIR->>TIR: return/break/continue inside → DeferControlFlowEscape (E0139)
TIR->>MIR: Stmt::Defer { body }
MIR->>MIR: push body onto defer_stack
MIR->>MIR: scoped_block: arm exception landing pads
MIR->>VM: scope exit → replay_defers_to_depth LIFO
MIR->>VM: return/throw → replay_defers_to_depth(0)
MIR->>VM: break/continue → replay_defers_to_depth(loop.defer_depth)
VM->>VM: exception unwind → innermost handler (defer landing pad)
VM->>VM: handler PC: sync dispatch pc for same-frame rethrows
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
⏭️ Performance benchmarks were skippedPerf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to To run them on this PR, do any of the following, then push a commit (or re-run CI):
|
|
No description provided. |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
baml_language/crates/baml_compiler_lexer/src/tokens.rs (1)
108-109: 💤 Low valueConsider adding a lexer test for the defer keyword.
While end-to-end tests exist in
baml_tests/tests/defer.rs, adding a simple lexer-level test here would improve granular coverage and follow the pattern oftest_exception_keywords(line 916).Example test to add
+ #[test] + fn test_defer_keyword() { + let tokens = lex_no_whitespace("defer"); + assert_eq!(tokens, vec![TokenKind::Defer]); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@baml_language/crates/baml_compiler_lexer/src/tokens.rs` around lines 108 - 109, A lexer test for the new Defer token variant should be added to improve test granularity and consistency. Add a new test function following the pattern of test_exception_keywords (referenced at line 916) that verifies the "defer" keyword is properly lexed into the Defer token type. This test should be added in the same tokens.rs file to provide lexer-level coverage for the keyword tokenization alongside the existing end-to-end tests in baml_tests/tests/defer.rs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 3747-3748: The `at_statement_recovery_boundary` function does not
include `TokenKind::Defer` as a statement boundary, even though `parse_stmt` now
handles defer statements. This causes the parser to continue parsing into defer
statements during error recovery instead of stopping at the boundary. Locate the
`at_statement_recovery_boundary` function and add a check for `TokenKind::Defer`
alongside the other statement boundary token checks to ensure proper error
recovery boundaries.
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 3219-3227: The `replay_defers_to_depth` function lowers defer
bodies inline, but if a defer body throws an exception, the subsequent
`emit_unwatch_to_depth` calls won't execute, causing watched locals to leak.
Implement an unwind/exception handling mechanism within `replay_defers_to_depth`
that guarantees `emit_unwatch_to_depth` cleanup runs even when a replayed defer
throws, rather than relying on post-replay cleanup emission in the same block.
Apply the same unwind path pattern to the other related locations mentioned
(lines 4462-4463, 11224-11229, and 11241-11247).
In `@baml_language/crates/baml_compiler2_tir/src/builder.rs`:
- Around line 6057-6065: In the Stmt::Defer pattern match where infer_expr is
called on the defer_body, the type state (such as variable assignments and type
narrowing) is not being restored after checking the defer body. Since defer
bodies execute at scope exit rather than immediately, any type state
modifications from analyzing the defer body should not leak into the subsequent
statements. Save the current type state before calling infer_expr on the
defer_body, then restore it after the call completes, ensuring that the type
analysis of the deferred body does not affect the type constraints of statements
following the defer statement.
In `@baml_language/crates/baml_tests/tests/defer.rs`:
- Around line 18-97: The defer.rs test file currently lacks explicit runtime
tests for defer execution via return, break, continue, and throw exit paths. Add
four new test functions (defer_runs_on_return, defer_runs_on_break,
defer_runs_on_continue, and defer_runs_on_throw) using the same baml_test! macro
pattern and assert_eq! validation approach as the existing tests
(defer_runs_lifo_on_normal_block_exit,
defer_is_block_scoped_not_function_scoped, defer_runs_per_loop_iteration, and
defer_sees_final_value_of_mutated_local) to verify that defers execute correctly
at each of these exit points.
---
Nitpick comments:
In `@baml_language/crates/baml_compiler_lexer/src/tokens.rs`:
- Around line 108-109: A lexer test for the new Defer token variant should be
added to improve test granularity and consistency. Add a new test function
following the pattern of test_exception_keywords (referenced at line 916) that
verifies the "defer" keyword is properly lexed into the Defer token type. This
test should be added in the same tokens.rs file to provide lexer-level coverage
for the keyword tokenization alongside the existing end-to-end tests in
baml_tests/tests/defer.rs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: fdbfaa47-533a-4c6d-ba67-afefad4f163f
📒 Files selected for processing (16)
baml_language/crates/baml_compiler2_ast/src/ast.rsbaml_language/crates/baml_compiler2_ast/src/lower_expr_body.rsbaml_language/crates/baml_compiler2_hir/src/builder.rsbaml_language/crates/baml_compiler2_mir/src/lower.rsbaml_language/crates/baml_compiler2_tir/src/builder.rsbaml_language/crates/baml_compiler2_tir/src/infer_context.rsbaml_language/crates/baml_compiler2_tir/src/throws_analysis.rsbaml_language/crates/baml_compiler2_visualization/src/control_flow/from_ast.rsbaml_language/crates/baml_compiler_diagnostics/src/diagnostic.rsbaml_language/crates/baml_compiler_lexer/src/tokens.rsbaml_language/crates/baml_compiler_parser/src/parser.rsbaml_language/crates/baml_compiler_syntax/src/ast.rsbaml_language/crates/baml_compiler_syntax/src/syntax_kind.rsbaml_language/crates/baml_lsp2_actions/src/check.rsbaml_language/crates/baml_tests/tests/defer.rsbaml_language/crates/tools_onionskin/src/compiler.rs
The `compiler2_tir` test helpers are `#[cfg(test)]`-only, so the prior defer commit (verified with `--test defer`) missed their exhaustive `Stmt` matches; `cargo test`/`--all-targets` did not build. Add the `Stmt::Defer` arms so the full workspace compiles.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@baml_language/crates/baml_tests/src/compiler2_tir/mod.rs`:
- Around line 740-743: The Stmt::Defer case is currently rendering defer bodies
using only expr_desc which produces a summary instead of structural output,
weakening the snapshot signal. Replace the single-line expr_desc rendering with
recursive structural rendering of the defer body to properly display compound
defer bodies. Apply this same fix to the second occurrence of defer body
rendering mentioned at lines 901-904.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: f517f6a3-aa88-4570-83c3-afbbc33edbc1
📒 Files selected for processing (1)
baml_language/crates/baml_tests/src/compiler2_tir/mod.rs
| Stmt::Defer { body: defer_body } => { | ||
| let desc = expr_desc(*defer_body, body); | ||
| writeln!(output, "{pad}defer {desc}").ok(); | ||
| } |
There was a problem hiding this comment.
Render compound defer bodies recursively in TIR dumps.
Both typed and untyped statement renderers print defer bodies via expr_desc(...) only, so block bodies are summarized instead of rendered structurally. That weakens snapshot signal for defer behavior.
💡 Proposed fix
@@
Stmt::Defer { body: defer_body } => {
- let desc = expr_desc(*defer_body, body);
- writeln!(output, "{pad}defer {desc}").ok();
+ if is_compound(&body.exprs[*defer_body]) {
+ writeln!(output, "{pad}defer").ok();
+ render_expr_body_untyped(body, *defer_body, indent + 2, output);
+ } else {
+ let desc = expr_desc(*defer_body, body);
+ writeln!(output, "{pad}defer {desc}").ok();
+ }
}
@@
Stmt::Defer { body: defer_body } => {
- let desc = expr_desc(*defer_body, body);
- writeln!(output, "{pad}defer {desc}").ok();
+ if is_compound(&body.exprs[*defer_body]) {
+ writeln!(output, "{pad}defer").ok();
+ render_expr(*defer_body, body, inference, indent + 2, output);
+ } else {
+ let desc = expr_desc(*defer_body, body);
+ writeln!(output, "{pad}defer {desc}").ok();
+ }
}Also applies to: 901-904
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@baml_language/crates/baml_tests/src/compiler2_tir/mod.rs` around lines 740 -
743, The Stmt::Defer case is currently rendering defer bodies using only
expr_desc which produces a summary instead of structural output, weakening the
snapshot signal. Replace the single-line expr_desc rendering with recursive
structural rendering of the defer body to properly display compound defer
bodies. Apply this same fix to the second occurrence of defer body rendering
mentioned at lines 901-904.
Document the confirmed `catch` bug: a throw propagating out of a called function (or a runtime panic) is caught by the OUTERMOST enclosing `catch` instead of the innermost, because the VM selects the first covering exception-table entry (smallest start_pc) rather than the innermost. `#[ignore]`d pending the exception-model fix (routing + panic/wildcard `ThrowIfPanic` rethrow reconciliation). Same root cause blocks `defer` running on the call-unwind path.
Two coupled exception-routing bugs, both on cold (unwind) paths only — the hot dispatch loop's local-pc optimization is untouched: 1. Routing: `try_unwind_exception` selected the FIRST exception-table entry covering the faulting PC (table sorted by ascending start_pc) = the OUTERMOST region. When several regions cover a PC (a `catch`/`defer` nested in another within one frame), the INNERMOST must win. Any throw that reaches the table — an exception propagating out of a called function, or a runtime panic — was mis-routed to the outermost handler. Now selects the innermost (largest start_pc) covering entry. Inline throws were unaffected (they lower to a direct jump), which masked it. 2. Same-frame pc sync: `Throw`/`ThrowIfPanic` call `try_unwind_exception` internally but never synced the dispatch loop's local `pc`. When the handler is in the SAME frame (inner wildcard catch rethrowing a panic to an outer catch in one function), the loop continued with the stale post-throw `pc` and fell through to the next statement instead of jumping to the handler. Now syncs `pc` to the handler entry after a same-frame unwind. Exposed once routing (1) sends panics to inner catches; this is what makes the wildcard `ThrowIfPanic` rethrow reach the outer handler. Regression test `nested_catch_inner_catches_callee_throw` (un-ignored). This also unblocks `defer` running on the call-unwind path.
Defers now also run when an exception propagates out of a called function (or a panic) — the BEP's motivating cleanup-on-error case — not just on the static exits (normal/return/break/continue/explicit throw). Lowering: each `defer` in a block splits the block and opens a catch-all unwind region whose landing pad replays that defer, then cascades to the next-outer pad / enclosing handler. The exception table routes a throw to the innermost region reached so far (relying on the innermost-handler routing fix), so only the defers armed before the throw run, LIFO. Explicit `throw` no longer inline-replays — it routes through the same pads, avoiding double execution. Non-throwing exits keep the inline replay path, which now clears the catch context so a defer that throws during inline replay propagates outward instead of re-entering its own pad. Tests: defer_runs_when_called_function_throws, _unwind_is_lifo_with_multiple_defers, _unwind_runs_only_armed_defers (baml_tests/tests/defer.rs). Full suite green.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 4541-4570: The defer pad execution order is incorrect regarding
when watched-local cleanup occurs. Currently, defers are replayed in LIFO order
within the loop over defer_pads.iter().rev(), but the
emit_unwatch_to_depth(watched_depth) cleanup is not being emitted after all
defers complete and before cascading to outer handlers. Restructure the logic so
that after the entire loop completes (after all LIFO defers have been replayed),
emit the block's unwatch cleanup via emit_unwatch_to_depth once, then proceed
with cascading to the outer route_ctx or throwing. Additionally, remove any
pre-unwatch operations from the throw paths within this loop that route through
active unwind pads, as the outermost defer pad will now handle the cleanup
centrally.
In `@baml_language/crates/bex_vm/src/vm.rs`:
- Around line 2758-2779: The current exception handler selection logic uses
max_by_key to pick handlers with the largest start_pc, but when multiple
handlers have the same start_pc (nested handlers starting at the same location),
it doesn't correctly identify the innermost handler. The fix is to modify both
the compact exception handlers lookup (in the if branch) and the legacy
exception handlers lookup (in the else branch) to use a custom comparator
instead of max_by_key that first compares by start_pc, and when start_pc values
are equal, tie-breaks by selecting the handler with the smaller end_pc value
(which represents the narrower, inner handler).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 7f4cadff-c6a2-410a-9e4c-429922bf79e5
📒 Files selected for processing (4)
baml_language/crates/baml_compiler2_mir/src/lower.rsbaml_language/crates/baml_tests/tests/defer.rsbaml_language/crates/baml_tests/tests/exceptions.rsbaml_language/crates/bex_vm/src/vm.rs
Verify the loop-aware control-flow-escape check: `return`/`break` that escape a `defer` body report E0139, while a `break` targeting a loop declared inside the defer (loop-aware) and a `throw` are allowed.
…r tie-break, recovery boundary, more tests - TIR: snapshot/restore scoped locals around the deferred body check so its narrowing/assignments of outer names don't leak into statements between the `defer` and scope exit (the body runs at scope exit, not the defer site). - bex_vm: select the innermost handler by range (largest start_pc, then narrowest end_pc) instead of relying on table insertion order. - parser: include `defer` in `at_statement_recovery_boundary` so error recovery stops at a `defer` instead of parsing into it. - tests: runtime coverage for return/break/continue/explicit-throw defer exits + await-in-defer; TIR E0139 escape tests (return/break escape error, inner-loop break + throw allowed). - Known limitation: a defer that itself throws mid-unwind skips sibling defers (PC-layout of the throwing pad); `#[ignore]`d test documents it, to be fixed with the cause-chain unwind rework.
A defer that itself throws while unwinding must still run the remaining (earlier-declared) defers (BEP-042). The throwing defer's pad body was lowered under the pad's own `route_ctx`, which gave the body's calls an unwind edge to the sibling pad and pulled it early in RPO — so the outer pad's exception-table region no longer covered the body's throw block, and the throw escaped, skipping the remaining defers. Lower the pad body under the ENCLOSING context instead; the exception table routes the body's throw to the next-outer pad (whose region now covers it), and the explicit cascade still handles a normally-completing defer body. Un-ignores defer_that_throws_still_runs_remaining_defers.
# Conflicts: # baml_language/crates/baml_compiler2_tir/src/builder.rs # baml_language/crates/baml_compiler_diagnostics/src/diagnostic.rs
The untyped and typed TIR-dump statement renderers printed defer bodies via a one-line expr_desc; render them structurally (like while/for bodies) so snapshot output reflects defer block contents. (CodeRabbit nit.)
…ML#3835) ## Summary Implements BEP-042's **`cleanup` finalizer**, the follow-up to `defer` (BoundaryML#3785). A class that defines a `cleanup(self) -> void` method gets a finalizer that runs **at most once per instance** — whether invoked explicitly, via `defer`, or automatically when the GC collects the instance. ```baml class Pool { connections Connection[] function cleanup(self) -> void { self.drain() self.disconnect() } } let p = Pool { connections: [] } defer { p.cleanup() } // runs once at scope exit // p.cleanup() again, or GC collecting p later → no-op (already cleaned) ``` **Divergence from BEP-042:** `cleanup` is a **magic method recognized by name** (like `to_json`), *not* the `implements baml.gc.Cleanup` interface the BEP describes. If a class defines `cleanup(self) -> void`, it's a finalizer; if not, nothing happens. Same lineage as `to_json`/`to_string` magic, no interface ceremony. Two commits. ## Commit 1 — foundation: magic method + run-once latch (no GC) - `cleanup(self) -> void` recognized by name; **reserved name + fixed shape** — a `cleanup` with any other signature is a compile error (`E0142`). - Per-instance atomic latch (`CleanupLatch` on `Instance`) — the analogue of .NET's `GC.SuppressFinalize` bit, shared by every trigger path so the body runs at most once. - The run-once check is a **compile-time guard** wrapping the user body: `if (baml._cleanup_begin(self)) { <body> }`. Because it lives in the callee, explicit calls and `defer { obj.cleanup() }` (and, in commit 2, the GC) are all deduped with **zero added cost on the VM's hot dispatch loop**. - The latch byte keeps `Instance` within `Object`'s 64-byte budget by boxing `class_type_args` (`Vec` → `Box<[RuntimeTy]>`, immutable after construction; matches the existing `Closure::captured_type_args` convention). Stored inline so it's correct across full *and* minor GC by construction. ## Commit 2 — GC finalizer path: the "forgot to `defer`" safety net Follows the .NET/JVM **keep-alive-then-finalize** model, adapted to the copying collector (validated against CPython/JVM/Go/.NET finalizer implementations): - `has_cleanup` flag on the runtime `Class`, set at emit time — so the common no-`cleanup` case is a single bit-check and never enters finalization. - Both the **major and minor** collectors scan the from-space (still intact pre-swap) for instances that are dead (not in the forwarding map), not already cleaned (the inline latch), and `has_cleanup`; each — plus its transitive closure — is kept alive for the cycle and queued. A from-space scan needs **no per-allocation registry**. - The engine drains the queue immediately after each collection (heap consistent, `checking_gc` held so no nested collection moves the queued pointers) and runs each `cleanup` on the real instance (by handle, never copied). A throwing finalizer is logged and swallowed — there is no caller to propagate to on the GC path (matches Python's `__del__`). ## Testing - `baml_tests/tests/cleanup.rs` — run-once on explicit double-call, via `defer`, explicit+`defer` sharing one latch, per-instance independence, and the `E0142` shape errors. - `bex_engine/tests/cleanup_finalizer.rs` — `cleanup` runs when an instance is GC-collected; an instance cleaned explicitly before becoming garbage is **not** re-finalized by the GC (SuppressFinalize). - Full `baml_tests` lib + integration suites and the GC unit/integration suites pass. Stdlib snapshots updated for the new internal `_cleanup_begin` builtin. ## Performance Ran the runtime benchmark suite before/after (divan, 38 benches, profiling off to isolate VM execution): **within noise** — mean Δ +1.6%, median +1.0%, with the larger outliers mixed-sign and concentrated on the known-high-variance benches. The GC/allocation-heavy benches (`grid_alloc`, `binary_tree`, `class_instances`) show no regression, so the from-space finalizer scan has no measurable cost on programs without `cleanup`. ## Deferred (per BEP-042's sequencing) - The error **`cause` chain** (`ErrorContext`) — a finalizer that throws mid-unwind still uses replace-semantics. - Finalization timing is non-deterministic; cross-type ordering is unspecified (use `defer` when ordering matters). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added BEP-042 `cleanup(self) -> void` magic finalizers with per-instance run-once behavior for explicit calls, `defer`, and GC-driven finalization. * **Bug Fixes** * Ensures cleanup runs at most once per instance; cleanup errors no longer trigger repeated finalization. * Improves instance type-argument handling during runtime conversions and union resolution. * **Bug Fixes** * Enforces the reserved `cleanup` signature and reports mismatches with compiler error **E0144**. * **Tests** * Added runtime and compile-time coverage for correct, deferred, GC behavior, and invalid signatures. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
…tx)) (BoundaryML#3886) ## What `catch (e, ctx)` now binds a second value — an **`ErrorContext`** — giving BAML implicit error chaining in the style of Python's `__context__`. When code throws a *new* error while handling another, the new error's `ctx.cause` automatically links back to the error being handled, and rendering the context prints the whole chain oldest-first: ``` <original error + stack trace> During handling of the above error, another error occurred: <new error + stack trace> ``` This completes BEP-042 (alongside `defer` BoundaryML#3785 and `cleanup` BoundaryML#3835). ## The type ```baml class ErrorContext { error unknown stack_trace StackTrace cause ErrorContext? } ``` - `baml.errors.ErrorContext`, registered in `baml_builtins2::ALL`. - pure-BAML `root_cause()` walks `cause` to the original failure. - `implements baml.ToString` — the native renderer prints the full chain. **Breaking change:** the optional second binding of `catch (e, ctx)` changes type from `StackTrace` to `ErrorContext` (the trace is now `ctx.stack_trace`). ## How it works `cause` is computed **lazily at the cold throw funnel** — no in-flight register, no per-edge teardown opcode. When a throw unwinds, the VM walks the live frames read-only; if the throw's PC lies in an active handler body, the error that handler is handling becomes the new error's `cause`. Handler-body extents live in a side table (`handler_context_table`), one **per-block** range so layout-fragmented handler bodies are covered exactly — no over-covering the gaps between fragments. The teardown design was chosen after studying how CPython/Go/JVM/.NET/V8 represent "currently-handling" state from source. Conclusion: BAML keeps no teardown opcode — the same move it already made going from `PushHandler`/`PopHandler` to exception tables. CPython is the lone VM that keeps a per-edge teardown opcode, and only for two constraints BAML provably lacks. ### Coverage - **Nested `catch { throw }`** — `throw B` while handling `A` ⇒ `B.cause = A`. - **Sibling defers** — `defer{throw A}; defer{throw B}; defer{throw C}; throw X` runs LIFO and chains `A → B → C → X`; `root_cause()` reaches `X`. ### Rethrow A bare re-raise (`throw e`, the no-match fall-through, `ThrowIfPanic`) is flagged as a *rethrow* and does **not** add a chain link — it re-propagates the same error rather than recording a new one (pairs with the `Rethrow` mechanism from BoundaryML#3844, so the no-match fall-through doesn't self-link onto its own context). > **Known limitation:** a prior chain carried by a rethrown value is not preserved across the re-raise (its next catch sees `cause = null`). This is a deliberate conservative choice — recovering it would need a value→context association we don't track, and reusing the cause-walk result instead would mis-attribute a nested outer-binding rethrow (binding the inner handler's error). ## Validation - New acceptance tests, **all proven non-vacuous** (each fails when its fix is reverted): root-cause-is-self, nested-catch chaining, two slot-liveness hazards, no-rethrow-self-link, layout-gap non-coverage, sibling-defer chain-to-root-cause. - `errorcontext` 7/7 · `exceptions` · `defer` · `cleanup` · `bytecode_format` · `bex_vm_types` green · full `baml_tests --lib` **1714/0**, no codegen snapshot drift · pre-commit clippy/fmt green. ## Follow-up (separate PR) Surfacing the cause chain in the BEX tracing system (so `RunError` carries the `cause`) builds on this and lands separately. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added error context tracking with chained causes and readable, stack-trace-aware error output. * Improved exception handling so nested errors preserve the original failure context during unwinding. * **Bug Fixes** * Fixed cause-chain resolution in nested handlers and edge cases involving deferred or fragmented handler bodies. * Preserved stack-trace/context data even when it appears unused, improving reliability of error reporting. * **Tests** * Added coverage for error-context chaining and updated stack-trace snapshots to reflect richer output. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Summary
Implements the
deferstatement from BEP-042 (Cleanup Semantics) — fully, including cleanup on the error / call-unwind path — and fixes a pre-existing exception-routing bug incatchthat the call-unwind work surfaced.defer { ... }schedules a block to run when the enclosing block exits, in LIFO order, on every exit path — normal completion,return,break/continue, explicitthrow, and an exception propagating out of a called function or a runtime panic. It is block-scoped (per-iteration inside loops), and the deferred block reads the live enclosing locals at exit (it is not a closure capturing values at thedefersite).This is intentionally
defer-only. BEP-042 also specifies acleanupfinalizer and an errorcausechain; per the BEP's own "should not be gated as a single unit" guidance, those are scoped out of this PR — decisions for them are recorded below so the follow-ups are unambiguous.Locked-in decisions (this PR)
Agreed with the shepherd before implementation:
deferonly — nocleanup, no cause chain, no change to thecatch (e, st)StackTracebinding.return,break/continue, explicitthrow, and an exception propagating out of a called function / a runtime panic.awaitin a deferreturn/break/continuethat would escape a defer body is a compile error (E0139). Onlythrowmay leave a defer. Loop-aware: abreak/continuetargeting a loop declared inside the defer body is allowed.What this PR delivers
End-to-end through the
compiler2pipeline, no new VM opcode:deferkeyword,DEFER_STMT,Stmt::Defer { body }lowered inline as a block (not a synthetic lambda likespawn), so it reads the enclosing scope.loop_depth+defer_loop_floorscontext, withreturn/break/continueescapes reported asTirTypeError::DeferControlFlowEscape(E0139).throwinside a defer is permitted and is folded into the function's throws contract.defer_stackparallel to the existingwatched_locals_stack, replayed (LIFO, re-lowered inline / block-duplicated) at every static scope exit (normal fall-through,return,break/continue), and swapped at lambda boundaries.defersplits its block and opens a catch-all unwind region whose landing pad replays that defer then cascades to the next-outer pad / enclosing handler. The exception table routes a throw to the innermost region reached so far, so only the defers armed before the throw run, LIFO. Explicitthrowroutes through the same pads (no inline double-replay). Reuses the existing exception-table machinery (CatchRegion/catch_context/build_exception_table).baml_tests/tests/defer.rs: LIFO on normal exit, block-scoping (not function-scoping), per-iteration in loops, final-value capture, defer runs when a called function throws, multi-defer LIFO on call-unwind, only-armed-defers-run on an early throw. Full suite green.Exception-routing fix in
catch(included here)Running defers on the call-unwind path surfaced a pre-existing, general
catchbug: a throw propagating out of a called function (or a runtime panic) was caught by the outermost enclosingcatchinstead of the innermost. (({ boom(); 0 } catch { 1 }) catch { 2 }returned2, not1.) Inlinethrows were unaffected because they lower to a direct jump, which is why this was hidden. Two coupled fixes, both on cold (unwind) paths only — the hot dispatch loop is untouched:try_unwind_exceptionselected the first exception-table entry covering the faulting PC (table sorted by ascendingstart_pc) = the outermost region. When several regions cover a PC (acatch/defernested in another within one frame), the innermost must win. Now selects the innermost (largeststart_pc) covering entry.pcsync —Throw/ThrowIfPaniccalltry_unwind_exceptioninternally but never synced the dispatch loop's localpc. When the handler is in the same frame (an inner wildcard catch rethrowing a panic to an outer catch in one function), the loop continued with the stale post-throwpcand fell through to the next statement instead of jumping to the handler. Now syncspcafter a same-frame unwind.Regression test:
nested_catch_inner_catches_callee_throw(baml_tests/tests/exceptions.rs). The existingbaml_src/ns_exceptionswildcard panic-rethrow tests continue to pass.Deferred BEP-042 work — decisions for future PRs
These were discussed and settled in this round so the follow-ups don't re-litigate:
cleanup(the magic finalizer) — separate, larger effortbex_vm) is a generational tracing GC with no object-finalization machinery (finalize()only clears the eval/call stacks;Instancehas no per-instance header).cleanuptherefore needs a genuinely new subsystem: a finalizer registry, GC integration (keep-alive-then-finalize), and the cross-thread atomic call-once latch. Per BEP-042's sequencing, shipdeferfirst and landcleanuponce finalization exists.cleanup(self) -> void(no interface — consistent withto_string/to_json); reserved name + fixed shape; auto-registered per instance; runs at most once via the latch (explicit call,defer, or GC all share it); errors on the GC path are logged and ignored.Error
causechain /ErrorContext— coupled to defer, deferred with it__context__— when athrow/panic occurs while a prior error is in flight, the prior error becomes the new error'scause. Rethrow detection is by value identity (a barethrow eof the same value adds no link); the in-flight error is cleared when acatcharm recovers normally. The optional secondcatchbinding changes type fromStackTraceto a recursivebaml.errors.ErrorContext { error, stack_trace, cause, root_cause(), to_string() }(an intentional breaking change). This requires the VM to track the in-flightErrorContextthrough unwinding.Known limitations
unwatched when a scope unwinds through a defer landing pad. Awatch let(BEP-024) whose enclosing scope is left by an exception propagating out of a call — i.e.watch+defer+ a call-unwind — leaves its watcher registered. This is a narrow combination and is consistent with the existingcatchpath, which likewise does notunwatchon the exception-unwind path. Thereturn/ explicit-throw/ normal-exit paths do unwatch as before. To be addressed by the unwind-error rework that accompanies thecausechain (see Deferred work).🔗 BEP-042:
BEPS/BEP-042-cleanup-semantics-defer-finally-and-destructorsSummary by CodeRabbit
Release Notes
New Features
deferstatement for deferred code execution. Deferred blocks run on all exit paths (normal completion, exceptions, return/break/continue) in LIFO order within their scope.return,break, andcontinuewithin defer bodies.Bug Fixes
Tests