Skip to content

fix(compiler): don't sink a panicking rvalue to its use site - #4354

Merged
hellovai merged 3 commits into
canaryfrom
vbv/b-1184
Aug 10, 2026
Merged

fix(compiler): don't sink a panicking rvalue to its use site#4354
hellovai merged 3 commits into
canaryfrom
vbv/b-1184

Conversation

@hellovai

@hellovai hellovai commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes B-1184: defer runs twice when a panic unwinds through it.

Root cause

Not in the VM unwinder — in the emitter. can_be_virtual (baml_compiler2_emit/src/analysis.rs) classifies a single-use local as Virtual, which drops the store at the definition and re-emits the rvalue at the use site. For a cross-block def-use that moves the evaluation past the def block's terminator (a call) and into a possibly different exception region.

MIR for the repro is correct:

bb1: { _0 = const 10 / const 0;
       _4 = call baml.Array.push(copy _1, const "D") -> [bb2]; }   // inline defer replay
bb2: { return; }
bb3: { _5 = call baml.Array.push(copy _1, const "D") -> [bb4]; }   // unwind landing pad
bb4: { rethrow copy _2; }

The emitted bytecode is not — _0 is virtualized to the return in bb2, so the division lands after the replay:

0  load_var log; load_const "D"; call push; pop   <- inline defer replay
4  load_const 10; load_const 0; div_int           <- panics here
7  return
8  [pad] load_var log; load_const "D"; call push; pop
12 rethrow

The defer body runs once on the way out, the division then panics, and the landing pad runs it again. With nested defers the entire chain repeats.

Typed throws are unaffected because throw is its own terminator — there is nothing to sink past.

The bug is not defer-specific

The same reorder is observable with no defer anywhere:

function risky(log: string[]) -> int throws unknown {
  let x = 10 / 0
  log.push("after")   // ran before the panic
  return x
}

Fix

Reject cross-block virtualization when the rvalue can panic: int add/sub/mul/shift (range-checked → IntegerOverflow), / and % (zero divisor → DivisionByZero), and negation (INT_MIN). Bitwise and/or/xor and comparisons stay in range by construction. Index reads can panic too, but every rvalue with a projection read is already rejected cross-block.

Same-block virtualization is untouched: exception regions start and end on block boundaries, so the set of covering handlers is constant within a block, and the existing intervening-side-effect check still applies.

Tests

Four new cases in crates/baml_tests/tests/defer.rs. Verified they fail on the parent commit with exactly the reported symptoms:

test before after
defer_runs_once_when_a_panic_unwinds_through_it ["D", "D"] ["D"]
nested_defers_run_once_each_when_a_panic_unwinds ["inner","outer","inner","outer"] ["inner","outer"]
defer_runs_once_when_a_call_free_body_sees_a_panic 8 9
a_panicking_binding_runs_before_later_side_effects ["after"] []

The third covers a defer body that compiles to statements rather than a call, so the replay lands in the returning block itself; the fourth is the underlying emitter bug with no defer involved.

Local run: cargo test -p baml_tests --test defer — 17 passed. Leaving the full suite to CI.

Draft pending a green CI run — the bytecode-snapshot tests are the ones worth watching, since this makes the emitter strictly less aggressive.

🤖 Generated with Claude Code

https://claude.ai/code/session_018D2txzPqfLJXdUcTit3uqk

Summary by CodeRabbit

  • Bug Fixes

    • Improved runtime behavior when arithmetic operations trigger panics, preventing unsafe evaluation changes across observable effects.
    • Ensured deferred actions execute exactly once during panic handling, in the correct last-in-first-out order and before subsequent side effects.
    • Division-by-zero panics are correctly surfaced and catchable.
  • Tests

    • Added coverage for panic unwinding, deferred execution, non-call defer bodies, and division-by-zero handling.

@linear

linear Bot commented Aug 10, 2026

Copy link
Copy Markdown

B-1184

@vercel

vercel Bot commented Aug 10, 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 Aug 10, 2026 6:46pm
promptfiddle2 Ready Ready Preview Aug 10, 2026 6:46pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 705d4c55-ce34-43fd-abf6-a2a97c81cb84

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The compiler now prevents cross-block virtual-local inlining for potentially panicking rvalues. New runtime tests cover defer execution and ordering during division-by-zero panic unwinding.

Changes

Panic-aware MIR execution

Layer / File(s) Summary
Panic-aware rvalue analysis
baml_language/crates/baml_compiler2_emit/src/analysis.rs
MIR analysis classifies arithmetic, negation, division, modulo, comparisons, operands, and runtime types. Cross-block virtualization rejects rvalues that may panic.
Defer panic-unwinding coverage
baml_language/crates/baml_tests/tests/defer.rs
Tests verify single execution, LIFO ordering, direct field mutation, and termination before later side effects during division-by-zero panic unwinding.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Poem

A rabbit checks each rvalue twice,
No panic slips through hidden ice.
Defers hop inward, then outward in line,
LIFO keeps their order fine.
Division falls; side effects wait.
Tests guard the unwind gate.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main compiler fix: preventing potentially panicking rvalues from being moved to their use sites.
✨ 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 vbv/b-1184

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

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 – beps August 10, 2026 07:19 Inactive
@hellovai
hellovai marked this pull request as ready for review August 10, 2026 07:24
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 10, 2026 07:27 Inactive
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Binary size checks passed

7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
baml-cli Linux 🔒 27.8 MB 11.8 MB file 27.4 MB +424.2 KB (+1.6%) OK
packed-program Linux 🔒 18.0 MB 7.4 MB file 17.7 MB +300.8 KB (+1.7%) OK
baml-cli macOS 🔒 21.6 MB 10.3 MB file 21.3 MB +329.4 KB (+1.5%) OK
packed-program macOS 🔒 14.1 MB 6.5 MB file 13.8 MB +244.0 KB (+1.8%) OK
baml-cli Windows 🔒 23.3 MB 10.5 MB file 23.0 MB +298.4 KB (+1.3%) OK
packed-program Windows 🔒 15.0 MB 6.6 MB file 14.8 MB +225.5 KB (+1.5%) OK
bridge_wasm WASM 17.1 MB 🔒 4.7 MB gzip 4.6 MB +48.7 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

@vercel
vercel Bot temporarily deployed to Preview – beps August 10, 2026 10:21 Inactive
coderabbitai[bot]
coderabbitai Bot previously requested changes Aug 10, 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: 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_compiler2_emit/src/analysis.rs`:
- Around line 1703-1706: Update the Rvalue::BinaryOp handling in
operand_could_be_int so BinOp::Div only returns true when both operands could be
integers, while BinOp::Mod no longer creates an unconditional barrier; preserve
the existing paired-operand checks for the other arithmetic and shift operators.
Run cd baml_language && cargo test --lib.
🪄 Autofix

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 Plus

Run ID: 605a8d76-40d4-405e-8ab6-095afcdd5f6e

📥 Commits

Reviewing files that changed from the base of the PR and between 0f400f7 and 0f3fbd0.

⛔ Files ignored due to path filters (15)
  • baml_language/crates/baml_tests/snapshots/baml_src/array_rest_binding.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/cancel_cascade.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/classes.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/comparable_sort.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/functions.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/interfaces_associated_types.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/ints.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/lambdas.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/lexical_scoping.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/optional_function_parameters.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/patterns_new_runtime.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/baml_src/task_group.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/lambda_fat_arrow/baml_tests__compiles__lambda_fat_arrow__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/lexical_scoping/baml_tests__compiles__lexical_scoping__06_codegen.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/snapshots/compiles/parser_statements/baml_tests__compiles__parser_statements__06_codegen.snap is excluded by !**/*.snap
📒 Files selected for processing (2)
  • baml_language/crates/baml_compiler2_emit/src/analysis.rs
  • baml_language/crates/baml_tests/tests/defer.rs

Comment thread baml_language/crates/baml_compiler2_emit/src/analysis.rs
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 10, 2026 10:28 Inactive
hellovai added a commit that referenced this pull request Aug 10, 2026
Addresses CodeRabbit on #4354. `%` has no specialized float opcode: the
generic float path does `l % r` and yields NaN, so only `ModInt`
(bex_vm/src/vm.rs) throws `DivisionByZero`. Ask `operand_could_be_int` like
the other int-only failures.

`/` stays unconditional. `OpCode::DivFloat` throws `DivisionByZero` on a zero
divisor rather than yielding IEEE infinity, so float division is fallible too.

No generated code changes — cross-block `%` virtualization does not occur in
the test corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018D2txzPqfLJXdUcTit3uqk
@vercel
vercel Bot temporarily deployed to Preview – beps August 10, 2026 17:27 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 10, 2026 17:34 Inactive
@hellovai

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

The outstanding comment is resolved: the % half was applied in d9ae785, and you agreed the unconditional Div barrier is correct for BAML. CI is green (61 checks). Please re-review so the CHANGES_REQUESTED state reflects the current head.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

@hellovai I will review pull request #4354 at the current head, including the Rem panic barrier in d9ae785.

⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

hellovai and others added 3 commits August 10, 2026 11:13
The emitter's `Virtual` classification inlines a single-use local's rvalue at
its use site instead of storing it. For a cross-block def-use that *moves* the
evaluation past the def block's terminator (a call) and into a possibly
different exception region.

Arithmetic can panic, so the move is observable. `return 10 / 0` inside a
function with a `defer` sank the division below the `return`'s inline defer
replay: the defer body ran on the way out, the division then panicked, and the
unwind landing pad ran the body a second time. With nested defers the whole
chain repeated (`inner,outer,inner,outer`). The same reorder is visible without
any `defer` — `let x = 10 / 0` followed by a call ran the call first.

Reject cross-block virtualization for rvalues that can panic: `int`
add/sub/mul/shift (range-checked), `/` and `%` (zero divisor), and negation
(`INT_MIN`). Index reads can panic too but are already rejected cross-block by
the projection-read guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018D2txzPqfLJXdUcTit3uqk
Follow-up to the previous commit. Two refinements plus the snapshot updates
it implies.

`rvalue_can_panic` treated every `+`/`-`/`*`/shift as fallible regardless of
operand type, which blocked virtualization for string concatenation — a chain
of concats round-tripped through slots instead of staying on the stack. Only
`int` arithmetic is range-checked: `float` saturates, `bigint` grows, and
`string + string` is concatenation. Ask the operand types, defaulting to
"could be int" for anything not pinned down. That drops the affected codegen
snapshots from 10 to 3.

The predicate also fell through to `_ => false`, asserting that every other
`Rvalue` is infallible. Wrong default for a soundness check: a panicking
variant added later would silently re-enable the code motion. Match all 19
variants so a new one fails to compile instead.

Remaining snapshot churn is arithmetic materialized at its binding site rather
than sunk past a later call, which is the fix working. Several cases lose an
instruction: the operand that used to need a slot now stays on the stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018D2txzPqfLJXdUcTit3uqk
Addresses CodeRabbit on #4354. `%` has no specialized float opcode: the
generic float path does `l % r` and yields NaN, so only `ModInt`
(bex_vm/src/vm.rs) throws `DivisionByZero`. Ask `operand_could_be_int` like
the other int-only failures.

`/` stays unconditional. `OpCode::DivFloat` throws `DivisionByZero` on a zero
divisor rather than yielding IEEE infinity, so float division is fallible too.

No generated code changes — cross-block `%` virtualization does not occur in
the test corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018D2txzPqfLJXdUcTit3uqk
@vercel
vercel Bot temporarily deployed to Preview – beps August 10, 2026 18:39 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 10, 2026 18:46 Inactive
@hellovai
hellovai dismissed coderabbitai[bot]’s stale review August 10, 2026 19:18

Stale: this review predates d9ae785/46af8cb. Its one comment had two parts — the % half was applied, and the reviewer withdrew the / half in #4354 (comment) ("My original division recommendation incorrectly applied Rust float semantics instead of BAML VM semantics"). Thread resolved; CI green at 61 checks.

@hellovai
hellovai added this pull request to the merge queue Aug 10, 2026
Merged via the queue into canary with commit d4e70d0 Aug 10, 2026
83 checks passed
@hellovai
hellovai deleted the vbv/b-1184 branch August 10, 2026 19:30
aaronvg added a commit that referenced this pull request Aug 10, 2026
CI reported two bytecode snapshot failures that I could not reproduce
locally, which sent me chasing nondeterminism and platform divergence.
Neither was right. CI tests the PR MERGED with canary, and canary had
landed d4e70d0, "fix(compiler): don't sink a panicking rvalue to its
use site" (#4354).

That fix changes `can_be_virtual`: a single-use local is no longer
virtualized away, because dropping the store at the definition and
re-emitting at the use site made the rvalue run a second time when a
panic unwound through it. So the emitted code now materializes
`store_var _14` at the definition instead of sinking it, which in turn
leaves two loads adjacent and lets the peephole fuse them into
`load_var2`. My snapshots predated the fix and encoded the old sunk
form.

The tell was the test count: CI ran 3188 where I ran 3184, and the four
extra were that fix's own regression tests. Merging canary reproduces
CI's output exactly, byte for byte.

Regenerated the four affected snapshots. Local run is now 3188 tests,
3185 passing, no unreferenced snapshots; the 3 failures are the local
gofmt shim with no go version set, which is green in CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
antoniosarosi added a commit that referenced this pull request Aug 11, 2026
Canary #4354 added the exhaustive rvalue_can_panic soundness predicate;
BEP-066's RuntimeIsType and CurrentPackage variants are both infallible
(a type test like IsType, and a build-time-resolved lookup like LoadType)
so they join the non-panicking group. Regenerated the describe listing
and bytecode snapshots for the merged stdlib + new Package API methods.
antoniosarosi added a commit that referenced this pull request Aug 11, 2026
Canary #4354 added the exhaustive rvalue_can_panic soundness predicate;
BEP-066's RuntimeIsType and CurrentPackage variants are both infallible
(a type test like IsType, and a build-time-resolved lookup like LoadType)
so they join the non-panicking group. Regenerated the describe listing
and bytecode snapshots for the merged stdlib + new Package API methods.
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