Skip to content

feat(compiler2): add defer statement (BEP-042) - #3785

Merged
antoniosarosi merged 11 commits into
canaryfrom
antoniosarosi/defer
Jun 22, 2026
Merged

antoniosarosi merged 11 commits into
canaryfrom
antoniosarosi/defer

Conversation

@antoniosarosi

@antoniosarosi antoniosarosi commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements the defer statement from BEP-042 (Cleanup Semantics) — fully, including cleanup on the error / call-unwind path — and fixes a pre-existing exception-routing bug in catch that the call-unwind work surfaced.

let conn = db.connect()
defer { conn.close() }          // runs at scope exit, however we leave

let result = query(conn) catch (e) {
  _ => { log(e); throw e }      // rethrow — conn.close() still runs
}

defer { ... } schedules a block to run when the enclosing block exits, in LIFO order, on every exit path — normal completion, return, break/continue, explicit throw, 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 the defer site).

This is intentionally defer-only. BEP-042 also specifies a cleanup finalizer and an error cause chain; 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:

Decision Choice
Scope of this pass defer only — no cleanup, no cause chain, no change to the catch (e, st) StackTrace binding.
Runs on normal completion, return, break/continue, explicit throw, and an exception propagating out of a called function / a runtime panic.
Ordering LIFO (last declared runs first).
Scoping Block-scoped (not Go's function-scoped) → runs per iteration inside loops.
Variable capture Sees the final value of enclosing locals at exit (reads the live scope; not a closure snapshot).
Result value A defer cannot alter the enclosing block's result value.
await in a defer Allowed.
Escaping control flow return/break/continue that would escape a defer body is a compile error (E0139). Only throw may leave a defer. Loop-aware: a break/continue targeting a loop declared inside the defer body is allowed.
A defer that throws mid-unwind Uses replace-semantics (most-recent throw propagates); remaining defers still run. No cause preservation in this pass — see deferred work.
Cancellation Defers do not run when a spawned task is cancelled (BEP-034). Deliberately left open — to be revisited.
Throw-before-a-later-defer Stair-step nested unwind regions — only the defers armed before the throw run, LIFO (not a single over-running landing pad). Implemented.

What this PR delivers

End-to-end through the compiler2 pipeline, no new VM opcode:

  • Lexer / parser / CST / AST — defer keyword, DEFER_STMT, Stmt::Defer { body } lowered inline as a block (not a synthetic lambda like spawn), so it reads the enclosing scope.
  • TIR — loop-aware escape validation: new loop_depth + defer_loop_floors context, with return/break/continue escapes reported as TirTypeError::DeferControlFlowEscape (E0139). throw inside a defer is permitted and is folded into the function's throws contract.
  • MIR — static exits — a defer_stack parallel to the existing watched_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.
  • MIR — call-unwind (Stage 2) — each defer splits 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. Explicit throw routes through the same pads (no inline double-replay). Reuses the existing exception-table machinery (CatchRegion / catch_context / build_exception_table).
  • Tests — 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.
// Stage 1: static exit
function main() -> string {
  let log = "start;"
  {
    defer { log = log + "A;" }   // declared 1st → runs last
    defer { log = log + "B;" }   // declared 2nd → runs first
    log = log + "body;"
  }                              // ← B then A run here
  log                            // "start;body;B;A;"
}
// Stage 2: cleanup when a called function throws (the BEP's motivating case)
function risky(log: string[]) -> void {
  defer { log.push("cleanup") }
  boom()                          // throws — defer still runs
}

Exception-routing fix in catch (included here)

Running defers on the call-unwind path surfaced a pre-existing, general catch bug: a throw propagating out of a called function (or a runtime panic) was caught by the outermost enclosing catch instead of the innermost. (({ boom(); 0 } catch { 1 }) catch { 2 } returned 2, not 1.) Inline throws 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:

  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. Now selects the innermost (largest start_pc) covering entry.
  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 (an 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 after a same-frame unwind.

Regression test: nested_catch_inner_catches_callee_throw (baml_tests/tests/exceptions.rs). The existing baml_src/ns_exceptions wildcard 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 effort

  • Not built now, by design. The runtime VM (bex_vm) is a generational tracing GC with no object-finalization machinery (finalize() only clears the eval/call stacks; Instance has no per-instance header). cleanup therefore 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, ship defer first and land cleanup once finalization exists.
  • Design when we do build it (per BEP): a magic method cleanup(self) -> void (no interface — consistent with to_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 cause chain / ErrorContext — coupled to defer, deferred with it

  • Not built now (it's the part that makes a throwing defer preserve the superseded error). Without it, a defer that throws mid-unwind uses replace-semantics (this PR's behavior).
  • Design confirmed for later: temporal chaining à la Python __context__ — when a throw/panic occurs while a prior error is in flight, the prior error becomes the new error's cause. Rethrow detection is by value identity (a bare throw e of the same value adds no link); the in-flight error is cleared when a catch arm recovers normally. The optional second catch binding changes type from StackTrace to a recursive baml.errors.ErrorContext { error, stack_trace, cause, root_cause(), to_string() } (an intentional breaking change). This requires the VM to track the in-flight ErrorContext through unwinding.

Known limitations

  • Watched locals are not unwatched when a scope unwinds through a defer landing pad. A watch 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 existing catch path, which likewise does not unwatch on the exception-unwind path. The return / explicit-throw / normal-exit paths do unwatch as before. To be addressed by the unwind-error rework that accompanies the cause chain (see Deferred work).

🔗 BEP-042: BEPS/BEP-042-cleanup-semantics-defer-finally-and-destructors

Summary by CodeRabbit

Release Notes

  • New Features

    • Added defer statement for deferred code execution. Deferred blocks run on all exit paths (normal completion, exceptions, return/break/continue) in LIFO order within their scope.
    • Added control-flow validation to prevent invalid uses of return, break, and continue within defer bodies.
  • Bug Fixes

    • Fixed exception handler routing to correctly select the innermost matching catch/defer region when multiple overlapping handlers exist.
  • Tests

    • Added comprehensive test coverage for defer execution, exception unwinding, and control-flow validation.

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.
@vercel

vercel Bot commented Jun 17, 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 Jun 22, 2026 3:03pm
promptfiddle Ready Ready Preview, Comment Jun 22, 2026 3:03pm
promptfiddle2 Ready Ready Preview, Comment Jun 22, 2026 3:03pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Implements the defer { BODY } statement (BEP-042) end-to-end: lexer keyword, parser, typed syntax node, compiler2_ast IR variant, HIR semantic index, TIR type-checking with DeferControlFlowEscape (E0139) diagnostics for illegal return/break/continue escape, MIR defer_stack with LIFO replay at every scope/control-flow exit, VM exception-handler selection and PC dispatch corrections, LSP wiring, runtime tests, and visualization rendering.

Changes

BEP-042: defer statement full pipeline

Layer / File(s) Summary
Lexer keyword, syntax kinds, parser, and typed syntax node
baml_language/crates/baml_compiler_lexer/src/tokens.rs, baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs, baml_language/crates/baml_compiler_parser/src/parser.rs, baml_language/crates/baml_compiler_syntax/src/ast.rs
TokenKind::Defer maps to "defer"; KW_DEFER and DEFER_STMT syntax kinds added; parse_defer_stmt parses defer { BODY } into the CST; DeferStmt wraps it in the typed syntax layer and integrates into block element iteration and trailing semicolon logic.
compiler2_ast IR variant and AST/HIR lowering
baml_language/crates/baml_compiler2_ast/src/ast.rs, baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs, baml_language/crates/baml_compiler2_hir/src/builder.rs
Stmt::Defer { body: ExprId } is added to the AST enum with BEP-042 semantics documentation; lower_defer_stmt extracts the block body, lowers it inline, and emits the variant (falling back to Expr::Missing); SemanticIndexBuilder traverses defer bodies to record scope ownership and bindings.
Diagnostic infrastructure: DeferControlFlowEscape (E0139)
baml_language/crates/baml_compiler2_tir/src/infer_context.rs, baml_language/crates/baml_compiler_diagnostics/src/diagnostic.rs, baml_language/crates/baml_lsp2_actions/src/check.rs
TirTypeError::DeferControlFlowEscape { keyword } is added with formatted Display output; DiagnosticId::DeferControlFlowEscape is mapped to error code E0139; LSP action layer wires the TIR error to the diagnostic ID.
TIR type checking, defer/loop state tracking, and control-flow validation
baml_language/crates/baml_compiler2_tir/src/builder.rs, baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs
TypeInferenceBuilder adds loop_depth and defer_loop_floors to track nesting; Stmt::Defer branch pushes/pops loop floor and infers body (non-diverging); return/break/continue emit DeferControlFlowEscape when inside active defer; loop statements increment/decrement loop_depth; lambda bodies save/restore both state fields; throws and forward-reference analysis traverse defer bodies.
MIR defer_stack, LIFO replay, and control-flow integration
baml_language/crates/baml_compiler2_mir/src/lower.rs
LoweringContext gains defer_stack: Vec<AstExprId>; LoopContext gains defer_depth; replay_defers_to_depth re-lowers deferred bodies in LIFO order; lower_scoped_block replays and truncates on scope exit and arms exception landing pads for stage-2 unwind; return/throw replay to depth 0; break/continue replay to loop-relative depth; lambda entry/exit snapshot and restore the stack; AstStmt::Defer { body } pushes onto the stack.
VM exception handler selection and PC dispatch fixes
baml_language/crates/bex_vm/src/vm.rs
try_unwind_exception now selects the innermost covering exception-table entry via `max_by_key(
Runtime behavior tests and rendering/visualization support
baml_language/crates/baml_tests/tests/defer.rs, baml_language/crates/baml_tests/tests/exceptions.rs, baml_language/crates/baml_tests/src/compiler2_tir/*, baml_language/crates/baml_compiler2_visualization/src/control_flow/from_ast.rs, baml_language/crates/tools_onionskin/src/compiler.rs
Four async tests validate LIFO order at block exit, block-scoped not function-scoped, per-loop-iteration execution, and mutated-local final-value capture; three async tests validate exception unwinding behavior (defer execution during propagation, LIFO unwind order, armed-defer filtering); four TIR diagnostic tests verify return/break/continue escape errors and allow throw; regression test validates innermost catch handler routing; TIR snapshot rendering formats Stmt::Defer as defer <expr> in untyped/typed/HIR contexts; visualization layers render defer statements in onionskin TIR2 scope/line displays and traverse defer bodies in control-flow callee collection.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • BoundaryML/baml#3316: Modifies VM exception-table lookup logic (try_unwind_exception handler selection) that this PR's defer Stage-2 exception landing-pad execution depends on for correct innermost-handler routing.
  • BoundaryML/baml#3425: Updates MIR block-scope exit lowering and unwatch behavior; this PR's defer replay and exception landing-pad wiring interact with the same lower_scoped_block control-flow cleanup infrastructure.

Poem

🐰 Hop, hop, the defers stack high,
Last in, first out — a LIFO goodbye,
No return escapes the block's warm embrace,
Each scope exit runs at the right time and place.
Exceptions unwind through landing-pad floors,
The rabbit compiles—BEP-042 soars! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat(compiler2): add defer statement (BEP-042)' clearly and concisely summarizes the main feature being added—the defer statement implementation following the BEP-042 specification in the compiler2 pipeline.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 antoniosarosi/defer

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 and usage tips.

@github-actions

github-actions Bot commented Jun 17, 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.

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 17, 2026 12:00 Inactive
@github-actions

Copy link
Copy Markdown

No description provided.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
baml_language/crates/baml_compiler_lexer/src/tokens.rs (1)

108-109: 💤 Low value

Consider 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 of test_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

📥 Commits

Reviewing files that changed from the base of the PR and between 1581adc and 0666222.

📒 Files selected for processing (16)
  • baml_language/crates/baml_compiler2_ast/src/ast.rs
  • baml_language/crates/baml_compiler2_ast/src/lower_expr_body.rs
  • baml_language/crates/baml_compiler2_hir/src/builder.rs
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.rs
  • baml_language/crates/baml_compiler2_tir/src/infer_context.rs
  • baml_language/crates/baml_compiler2_tir/src/throws_analysis.rs
  • baml_language/crates/baml_compiler2_visualization/src/control_flow/from_ast.rs
  • baml_language/crates/baml_compiler_diagnostics/src/diagnostic.rs
  • baml_language/crates/baml_compiler_lexer/src/tokens.rs
  • baml_language/crates/baml_compiler_parser/src/parser.rs
  • baml_language/crates/baml_compiler_syntax/src/ast.rs
  • baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs
  • baml_language/crates/baml_lsp2_actions/src/check.rs
  • baml_language/crates/baml_tests/tests/defer.rs
  • baml_language/crates/tools_onionskin/src/compiler.rs

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 17, 2026 12:13 Inactive
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.
@vercel
vercel Bot temporarily deployed to Preview – beps June 17, 2026 14:50 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 17, 2026 14:55 Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 0666222 and b8902e5.

📒 Files selected for processing (1)
  • baml_language/crates/baml_tests/src/compiler2_tir/mod.rs

Comment on lines +740 to +743
Stmt::Defer { body: defer_body } => {
let desc = expr_desc(*defer_body, body);
writeln!(output, "{pad}defer {desc}").ok();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 17, 2026 15:08 Inactive
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.
@vercel
vercel Bot temporarily deployed to Preview – beps June 17, 2026 15:28 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 17, 2026 15:33 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 17, 2026 15:45 Inactive
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.
@vercel
vercel Bot temporarily deployed to Preview – beps June 20, 2026 15:14 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 20, 2026 15:19 Inactive
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.
@vercel
vercel Bot temporarily deployed to Preview – beps June 20, 2026 15:34 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 20, 2026 15:40 Inactive
coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 20, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 30575d6 and 9c8ae49.

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

Comment thread baml_language/crates/bex_vm/src/vm.rs Resolved
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 20, 2026 15:53 Inactive
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.
@vercel
vercel Bot temporarily deployed to Preview – beps June 22, 2026 11:06 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 22, 2026 11:12 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 22, 2026 11:23 Inactive
…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.
@vercel
vercel Bot temporarily deployed to Preview – beps June 22, 2026 12:11 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 22, 2026 12:16 Inactive
# Conflicts:
#	baml_language/crates/baml_compiler2_tir/src/builder.rs
#	baml_language/crates/baml_compiler_diagnostics/src/diagnostic.rs
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 22, 2026 12:29 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps June 22, 2026 12:34 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 22, 2026 12:41 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 22, 2026 12:52 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps June 22, 2026 14:30 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 22, 2026 14:36 Inactive
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.)
@vercel
vercel Bot temporarily deployed to Preview – beps June 22, 2026 14:46 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 June 22, 2026 14:51 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle June 22, 2026 15:03 Inactive
@antoniosarosi
antoniosarosi enabled auto-merge June 22, 2026 15:11
@antoniosarosi
antoniosarosi added this pull request to the merge queue Jun 22, 2026
Merged via the queue into canary with commit da4d557 Jun 22, 2026
52 checks passed
@antoniosarosi
antoniosarosi deleted the antoniosarosi/defer branch June 22, 2026 15:23
meefs pushed a commit to meefs/baml that referenced this pull request Jun 25, 2026
…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 -->
meefs pushed a commit to meefs/baml that referenced this pull request Jun 30, 2026
…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 -->

This branch was previously deployed

3 inactive deployments
Preview – promptfiddle — 26f58fa4 Deployed Jun 22, 2026 by vercel[bot]
Preview – promptfiddle2 — 26f58fa4 Deployed Jun 22, 2026 by vercel[bot]
Preview – beps — 26f58fa4 Deployed Jun 22, 2026 by vercel[bot]
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