Skip to content

Capture stack traces in thrown errors with source line numbers#3339

Merged
antoniosarosi merged 10 commits intocanaryfrom
antonio/add-stack-trace-api-to-error-handling
Apr 12, 2026
Merged

Capture stack traces in thrown errors with source line numbers#3339
antoniosarosi merged 10 commits intocanaryfrom
antonio/add-stack-trace-api-to-error-handling

Conversation

@antoniosarosi
Copy link
Copy Markdown
Contributor

@antoniosarosi antoniosarosi commented Apr 7, 2026

Artifacts | Task

What problems was I solving

Unhandled throws reaching Python/TypeScript/WASM included no call stack context — just a bare "uncaught throw: ..." message. Debugging required guesswork to trace which function threw and through which call chain. Additionally, BexVm::stack_trace() silently returned empty traces whenever closure frames were on the stack (the as_function() call failed on Object::Closure), and bytecode line tables were always empty because MIR statements carried no source spans.

What user-facing changes did I ship

  • Unhandled throw errors now include Python-style tracebacks with file paths and line numbers:
    Traceback (most recent call last):
      File "my_module.baml", line 14, in main
      File "my_module.baml", line 10, in caller
      File "my_module.baml", line 3, in thrower
    uncaught throw: "boom"
    
  • This surfaces automatically through all FFI bridges (Python, TypeScript/CFFI, WASM) via the Display impl — no bridge changes needed

How I implemented it

Phase 1: VM-layer plumbing

  • bex_vm_types/src/types.rs — Added source_file: String field to Function struct, populated at emit time with the filesystem path
  • bex_vm/src/types.rs — Added as_callable() to ObjectTrait that handles both Object::Function and Object::Closure, fixing the silent-empty-trace bug
  • bex_vm/src/errors.rs — Added file_path: String to ErrorLocation, added trace: Vec<ErrorLocation> to VmError::UnhandledThrow, updated StackTrace::Display to use file paths
  • bex_vm/src/vm.rs — Updated stack_trace() and Return handler to use as_callable(). Added trace capture at the top of try_unwind_exception() before frame unwinding destroys the call stack
  • bex_vm/src/package_baml/mod.rs — Updated attach_builtins Function copy site to include source_file
  • baml_compiler2_emit/src/lib.rs — Set source_file from file.path(db) at all Function construction sites (bytecode, builtin IO, builtin VM, synthesized)
  • baml_compiler2_emit/src/emit.rs — Added source_file: String::new() to compile_mir_function's Function construction

Phase 1.5: Line table population

  • baml_compiler2_mir/src/lower.rs — Added span_for_expr() and span_for_stmt() helpers that build Span from the AST source map. Added save/restore of builder.current_source_span at top/bottom of lower_expr() and lower_stmt() — all ~68 assign() call sites automatically inherit spans. Called builder.set_span() in lower_function_body() to populate MirFunction::span
  • baml_compiler2_emit/src/emit.rs — Updated compile_mir_function to accept mir_span: Option<Span> and apply it to Function::span
  • baml_compiler2_emit/src/lib.rs — Passed mir.span/lambda.span/None at all 3 call sites

Phase 2: Engine-layer propagation

  • bex_engine/src/lib.rs — Added trace: Vec<ErrorLocation> to EngineError::UnhandledThrow, added format_unhandled_throw helper for Python-style Display formatting, forwarded trace from VmError match arm
  • bex_vm/src/lib.rs — Added pub use errors::ErrorLocation re-export

Tests

  • baml_tests/tests/errors.rs — Un-ignored error_stack_trace, implemented with 4-frame call chain assertions, file_path and error_line > 0 verification
  • baml_tests/tests/exceptions.rs — Updated 4 UnhandledThrow match sites to use { value, .. }. Added exception_stack_trace_through_closures and exception_stack_trace_on_panic tests
  • Snapshot files — Regenerated bytecode display snapshots (line table entries now populated)

Deviations from the plan

Implemented as planned

  • All 4 phases (1, 1.5, 2, 3) implemented as designed
  • as_callable() dual-dispatch pattern, trace capture before unwinding, format_unhandled_throw helper, MIR span threading via current_source_span save/restore — all match the plan

Deviations/surprises

  • source_line_for_pc returns usize directly (not Option<usize>) — plan snippets showed .unwrap_or(0) but the actual API doesn't need it
  • $init_let_* helpers also get source_file set (plan didn't mention this site)
  • attach_builtins in package_baml/mod.rs needed updating for the new source_file field (plan's "search all construction sites" didn't enumerate this one)

Additions not in plan

  • PartialEq derived on ErrorLocation (required by EngineError's derive chain)
  • #[allow(unsafe_code)] on ObjectTrait impl block
  • Bytecode snapshot regeneration

Items planned but not implemented

  • None — all planned items were implemented

How to verify it

Setup

git fetch
git checkout antonio/add-stack-trace-api-to-error-handling
cd baml_language

Automated Tests

cargo test -p baml_tests --test errors        # error_stack_trace (was ignored, now passes)
cargo test -p baml_tests --test exceptions     # 75 tests including 2 new stack trace tests
cargo test -p bex_vm                           # VM tests
cargo test -p baml_tests                       # Full test suite (1613 tests)

Description for the changelog

Unhandled throws now include Python-style stack traces with file paths and line numbers, automatically surfaced through all FFI bridges.

Summary by CodeRabbit

  • New Features

    • End-to-end stack trace support: runtime captures file/line/function frames, StackTrace/StackFrame types, and formatted tostring for display.
    • Source-file info preserved for bytecode functions so tracebacks show source locations.
  • Bug Fixes

    • Uncaught throws and panics now include traceback info in error reports and VM error formatting.
  • Tests

    • Enabled and added snapshot tests asserting tracebacks for throws, panics, and catch (e, st) bindings.

…numbers

Thread file paths into Function, fix closure bug via as_callable(), capture
call stacks at throw time before frame unwinding, and propagate structured
Vec<ErrorLocation> traces through VmError and EngineError. Populate bytecode
line tables by setting MIR source spans in the lowering pass. Unhandled throws
now include Python-style tracebacks with file paths and line numbers in their
Display output, automatically surfaced through all FFI bridges.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 7, 2026

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

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Apr 12, 2026 7:40pm
promptfiddle Ready Ready Preview, Comment Apr 12, 2026 7:40pm

Request Review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 7, 2026

Binary size checks passed

7 passed

Artifact Platform Gzip Baseline Delta Status
bridge_cffi Linux 5.8 MB 5.7 MB +127.5 KB (+2.2%) OK
bridge_cffi-stripped Linux 5.8 MB 5.7 MB +108.1 KB (+1.9%) OK
bridge_cffi macOS 4.8 MB 4.6 MB +149.3 KB (+3.2%) OK
bridge_cffi-stripped macOS 4.8 MB 4.7 MB +92.9 KB (+2.0%) OK
bridge_cffi Windows 4.8 MB 4.6 MB +143.4 KB (+3.1%) OK
bridge_cffi-stripped Windows 4.8 MB 4.7 MB +94.3 KB (+2.0%) OK
bridge_wasm WASM 3.1 MB 3.0 MB +122.7 KB (+4.1%) OK

Generated by cargo size-gate · workflow run

Replace manual frame-by-frame assertions with insta::assert_snapshot!
for clearer, more maintainable stack trace test output.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 8, 2026

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

Threads source-file/span into MIR→bytecode emission, captures VM stack traces during unwinding, propagates traces through bytecode exception tables and engine errors, adds BAML StackTrace/StackFrame types and builtin formatting, and exposes an optional catch (e, st) stack-trace binding through the compiler pipeline.

Changes

Cohort / File(s) Summary
Syntax & Parser
baml_language/crates/baml_compiler_syntax/src/syntax_kind.rs, baml_language/crates/baml_compiler_syntax/src/ast.rs, baml_language/crates/baml_compiler_parser/src/parser.rs
Added CATCH_STACK_TRACE_BINDING syntax kind, accessor for it, and parser support for optional second binding in catch(...).
AST & 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
Extended CatchClause with stack_trace_binding, lowered CST to that field, and registered stack-trace bindings in semantic scopes.
Typing & TIR
baml_language/crates/baml_compiler2_tir/src/builder.rs
Resolved optional stack_trace_binding to baml.errors.StackTrace (or Unknown) and added locals for simple binding patterns.
MIR & Spans
baml_language/crates/baml_compiler2_mir/src/lower.rs, baml_language/crates/baml_compiler2_mir/src/ir.rs
Threaded per-expression/statement spans into MIR, set function-level span, and added CatchRegion.stack_trace_local: Option<Local>.
Emitter / Bytecode
baml_language/crates/baml_compiler2_emit/src/emit.rs, baml_language/crates/baml_compiler2_emit/src/lib.rs
compile_mir_function now accepts mir_span: Option<Span>; Function.source_file is set for user code; exception table entries now include stack_trace_slot computed from CatchRegion.stack_trace_local.
Bytecode Types
baml_language/crates/bex_vm_types/src/types.rs, baml_language/crates/bex_vm_types/src/bytecode.rs
Added Function.source_file: String; ExceptionTableEntry.stack_trace_slot: usize, NO_STACK_TRACE sentinel, and has_stack_trace_slot() helper.
VM Error & Trace Types
baml_language/crates/bex_vm/src/errors.rs, baml_language/crates/bex_vm/src/lib.rs
Replaced old StackTrace wrapper with StackFrame (adds file_path: String, derives PartialEq) and format_traceback; VmError gains ThrownUnhandled { value, trace }; re-exports updated.
VM Runtime
baml_language/crates/bex_vm/src/vm.rs, baml_language/crates/bex_vm/src/types.rs
Added alloc_stack_trace(&mut, trace) -> Value; use as_callable() for frame function lookup; capture Vec<StackFrame> on unwind and return ThrownUnhandled { value, trace }; bind allocated StackTrace to handler slot when present.
Engine Error Propagation
baml_language/crates/bex_engine/src/lib.rs
EngineError::UnhandledThrow now carries trace: Vec<bex_vm::StackFrame> and formats an optional traceback via bex_vm::format_traceback.
Package BAML / Builtins
baml_language/crates/bex_vm/src/package_baml/mod.rs, baml_language/crates/bex_vm/src/package_baml/stack_trace.rs, baml_language/crates/baml_builtins2/src/lib.rs
Preserve Function.source_file when attaching builtins; added stack_trace package implementation with StackTrace::to_string formatting; registered ns_errors/stack_trace.baml builtin.
BAML Stdlib Types
baml_language/crates/baml_builtins2/baml_std/baml/ns_errors/stack_trace.baml
Added StackFrame and StackTrace classes and a vm-backed StackTrace::to_string method.
Tests
baml_language/crates/baml_tests/tests/errors.rs, baml_language/crates/baml_tests/tests/exceptions.rs, baml_language/crates/baml_tests/tests/env.rs
Enabled and added stack-trace tests; updated assertions to destructure UnhandledThrow { value, trace }; updated snapshots to include formatted tracebacks.

Sequence Diagram

sequenceDiagram
    participant Caller as Compiler/Emitter
    participant VM as VM Runtime
    participant Frames as Frame Stack
    participant Trace as Trace Vec
    participant Handler as Catch Handler

    Caller->>VM: execute bytecode (user function)
    VM->>Frames: maintain frames (function, ip, source_file)
    Frames->>VM: on throw -> VM captures current frames
    VM->>Trace: map frames -> StackFrame(file_path,line,function)
    VM->>Handler: unwind, locate exception table entry
    alt entry.has_stack_trace_slot
        VM->>VM: alloc_stack_trace(trace) -> StackTrace Value
        VM->>Handler: store error and stack-trace value into locals
    else
        VM->>Handler: store error only
    end
    Handler->>Caller: execute catch body with bound locals
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through frames both near and far,
I gathered file and line like carrot stars.
When throws ran free, I traced their way,
Bound them in catch, then showed the play.
A tiny hop—now stack traces stay.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly and specifically summarizes the main change: capturing stack traces in thrown errors with source line numbers, which is the core feature across the entire changeset.

✏️ 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 antonio/add-stack-trace-api-to-error-handling

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.

coderabbitai[bot]
coderabbitai Bot previously requested changes Apr 8, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
baml_language/crates/bex_vm/src/vm.rs (1)

1038-1114: ⚠️ Potential issue | 🟠 Major

Preserve traceback state across rethrows instead of rebuilding it.

This trace is computed before the first unwind, but it is discarded as soon as a handler is found. The compiler-generated rethrows on unmatched arms and throw_if_panic then rebuild from the already-popped stack, so the original callee frames disappear and the reported line becomes the synthetic rethrow site. Cases like wrong_panic_pattern_propagates will surface truncated tracebacks unless the captured trace travels with the exception and is reused on rethrow.

baml_language/crates/baml_compiler2_emit/src/lib.rs (1)

1061-1122: ⚠️ Potential issue | 🟠 Major

Populate source_file for compiled lambdas.

Unlike the top-level bytecode path at Line 437 and the $init_let_* helper path at Line 1188, this branch only sets f.name. Every user-authored lambda therefore keeps the default empty source_file, so traceback frames that come from closures lose their filename even though this PR is supposed to surface file/line information through closures too.

Suggested fix
 fn compile_lambdas_flat(
     lambdas: &[baml_compiler2_mir::MirFunction],
     line_starts: &[u32],
+    source_file: &str,
     globals: &HashMap<String, usize>,
     classes: &HashMap<String, HashMap<String, usize>>,
     class_object_indices: &HashMap<String, usize>,
@@
                 let nested_info = compile_lambdas_flat(
                     &lambda.lambdas,
                     line_starts,
+                    source_file,
                     globals,
                     classes,
                     class_object_indices,
@@
                 let mut f = compile_mir_function(
                     body,
                     lambda.arity,
                     lambda.span,
                     line_starts,
                     ctx,
                     OptLevel::One,
                 );
                 f.name.clone_from(&lambda_name);
+                f.source_file = source_file.to_string();
                 let idx = objects.len();
                 objects.push(Object::Function(Box::new(f)));

Both callers can pass the same per-file path they already compute for top-level functions / $init_let_* helpers.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2e6b3d4e-ac35-4078-aeb7-56f4aa5e0fa0

📥 Commits

Reviewing files that changed from the base of the PR and between 3aa278c and d28ff29.

⛔ Files ignored due to path filters (2)
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap is excluded by !**/*.snap
📒 Files selected for processing (12)
  • baml_language/crates/baml_compiler2_emit/src/emit.rs
  • baml_language/crates/baml_compiler2_emit/src/lib.rs
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_tests/tests/errors.rs
  • baml_language/crates/baml_tests/tests/exceptions.rs
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/crates/bex_vm/src/errors.rs
  • baml_language/crates/bex_vm/src/lib.rs
  • baml_language/crates/bex_vm/src/package_baml/mod.rs
  • baml_language/crates/bex_vm/src/types.rs
  • baml_language/crates/bex_vm/src/vm.rs
  • baml_language/crates/bex_vm_types/src/types.rs

Comment on lines +915 to +919
// Set the function-level span on the builder so MirFunction::span is populated.
self.builder.set_span(baml_base::Span::new(
self.file.file_id(self.db),
func_data.span,
));
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

Also seed MirFunction::span for lambdas.

Line 915 initializes the top-level builder span, but lower_lambda() still creates a fresh MirBuilder without calling set_span. That leaves closure MirFunctions with empty function-level metadata even after this PR starts threading callable source locations into diagnostics.

📍 Suggested follow-up
 // in lower_lambda(...)
 self.builder = MirBuilder::new(Name::new(&lambda_name), arity);
+if let Some(span) = self.span_for_expr(expr_id) {
+    self.builder.set_span(span);
+}

Comment thread baml_language/crates/baml_tests/tests/exceptions.rs
Comment thread baml_language/crates/bex_engine/src/lib.rs Outdated
Comment on lines +43 to +48
Object::Closure(closure) => {
// SAFETY: closure.function points to a Function object that lives
// for the lifetime of the program (stored in the object pool).
// Same guarantee as in load_function() in vm.rs.
let func_obj: &Object = unsafe { closure.function.get() };
func_obj.as_function()
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C3 '\bstruct HeapPtr\b|\bfn get\s*\(' --glob '*.rs'
rg -n -C3 'load_function|closure\.function\.get\(' --glob '*.rs'
rg -n -C3 '\bapply_forwarding\b|\bforwarding\b' --glob '*.rs'

Repository: BoundaryML/baml

Length of output: 50371


Tighten the safety comment to match the actual contract.

The current safety comment claims closure.function "lives for the lifetime of the program," but this overstates the guarantee. The HeapPtr::get() contract requires only pointer validity and no concurrent writes, not a 'static lifetime. In reality, closure function objects are subject to GC and their pointers are updated via the forwarding map (see fixup_object_references in gc.rs:378). The dereference is safe here because it occurs during VM frame execution, where frame pointers are kept valid by apply_frame_forwarding() after each GC. The safety comment should reference this forwarding-based validity rather than claiming program-lifetime storage.

Resolve conflicts between stack trace capture feature and canary changes:
- Adapt to VmError::Thrown(Value) tuple variant (canary renamed from UnhandledThrow)
- Add VmError::ThrownUnhandled variant for terminal throws with trace data
- Keep VmInternalError return types on ObjectTrait methods
- Update EngineError::UnhandledThrow match sites to use { value, .. } pattern
- Regenerate bytecode format snapshots
- Add stack_trace.baml with StackFrame (file, line, function_name) and
  StackTrace (frames, to_string method) classes
- Implement BamlClassErrorsStackTrace::to_string in stack_trace.rs
- Add alloc_stack_trace helper to BexVm for constructing StackTrace
  instances from Vec<ErrorLocation> (used by Phase 5 catch binding)
Add optional second binding parameter to catch clauses that provides
a structured StackTrace object. The syntax `catch (e, st) { ... }`
binds the caught exception to `e` and the stack trace to `st`
(typed as baml.errors.StackTrace). Without the second parameter,
no stack trace is allocated — zero overhead for existing code.

Changes span every compiler layer:
- Parser: parse optional comma + second identifier after catch pattern
- SyntaxKind: add CATCH_STACK_TRACE_BINDING node type
- CST wrapper: add stack_trace_binding() accessor to CatchClause
- AST: add stack_trace_binding: Option<PatId> to CatchClause
- CST-to-AST lowering: extract second binding from CATCH_STACK_TRACE_BINDING
- HIR: register second binding in CatchClause scope
- TIR: type second binding as baml.errors.StackTrace via cross-package
  lookup, insert into locals for name resolution
- MIR: declare stack_trace_local, store in CatchRegion
- Emit: add stack_trace_slot to ExceptionTableEntry
- VM: construct and store StackTrace instance when handler has stack_trace_slot
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 10, 2026

Merging this PR will not alter performance

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

✅ 15 untouched benchmarks
⏩ 105 skipped benchmarks1


Comparing antonio/add-stack-trace-api-to-error-handling (6398e6f) with canary (661c525)

Open in CodSpeed

Footnotes

  1. 105 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
baml_language/crates/baml_compiler2_mir/src/lower.rs (1)

963-967: ⚠️ Potential issue | 🟡 Minor

Also seed lambda MirFunction::span.

This fixes top-level function metadata, but lower_lambda() still creates a fresh MirBuilder without calling set_span, so closure MirFunctions remain span-less. Any traceback/debug path that falls back to callable-level metadata can still lose source location for closure frames.

Suggested follow-up
// in lower_lambda(), right after constructing the fresh builder
self.builder = MirBuilder::new(Name::new(&lambda_name), arity);
if let Some(span) = self.span_for_expr(expr_id) {
    self.builder.set_span(span);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_compiler2_mir/src/lower.rs` around lines 963 - 967,
lower_lambda() constructs a fresh MirBuilder for closures (via MirBuilder::new /
self.builder) but never seeds its span, leaving MirFunction::span empty for
lambdas; after creating the new builder in lower_lambda(), call
self.span_for_expr(expr_id) (or otherwise obtain the closure expression span)
and pass it to self.builder.set_span(...) so the newly-built MirFunction has its
span populated for closure frames.
🧹 Nitpick comments (5)
baml_language/crates/baml_compiler_syntax/src/ast.rs (1)

2791-2796: Please confirm unit coverage for stack_trace_binding().

Given this is new AST API surface, add/confirm a focused unit test for both catch (e) and catch (e, st) parsing paths.

As per coding guidelines "Prefer writing Rust unit tests over integration tests where possible".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_compiler_syntax/src/ast.rs` around lines 2791 -
2796, Add focused Rust unit tests to cover the new AST API
stack_trace_binding(): write one test that parses a catch clause "catch (e)" and
asserts stack_trace_binding() returns None, and another that parses "catch (e,
st)" and asserts stack_trace_binding() returns Some node whose kind() equals
SyntaxKind::CATCH_STACK_TRACE_BINDING; place these as unit tests in the same
crate (near ast.rs tests) and use existing parser/helper utilities used
elsewhere in the crate to obtain the catch clause SyntaxNode and call
stack_trace_binding() for verification.
baml_language/crates/baml_compiler_parser/src/parser.rs (1)

3119-3125: Add a parser unit test for catch (e, st) grammar.

This new branch introduces CATCH_STACK_TRACE_BINDING; please add a parser-level unit test (including one malformed case like catch (e,)) to lock CST shape and recovery behavior.

As per coding guidelines "Prefer writing Rust unit tests over integration tests where possible".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_compiler_parser/src/parser.rs` around lines 3119 -
3125, Add parser-level Rust unit tests that assert the CST shape and recovery
for the new CATCH_STACK_TRACE_BINDING branch: write one positive test feeding
"catch (e, st)" and asserting that the CST includes a CATCH_STACK_TRACE_BINDING
node created by p.with_node (and that the second binding is a Word token), and
one malformed test feeding "catch (e,)" that verifies recovery produces a
CATCH_STACK_TRACE_BINDING node (or an expected error node) rather than
panicking; place these tests alongside the parser unit tests in the same crate
and use the existing parser test helpers/assertions to validate node kinds and
token positions.
baml_language/crates/bex_vm/src/package_baml/stack_trace.rs (1)

12-14: Consider defensive error handling instead of expect().

The expect("StackTrace: expected Instance") will panic if the value is not an instance. While this should never happen if the type system is working correctly, a more defensive approach could return an error message string instead of panicking.

💡 Defensive alternative
-        let instance = vm
-            .as_instance(stacktrace)
-            .expect("StackTrace: expected Instance");
+        let Some(instance) = vm.as_instance(stacktrace) else {
+            return "<invalid StackTrace>".to_string();
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/bex_vm/src/package_baml/stack_trace.rs` around lines 12
- 14, Replace the panic-causing expect on vm.as_instance(stacktrace) with
defensive error handling: call vm.as_instance(stacktrace) and if it returns
Some(instance) continue, otherwise return or propagate an Err(String) (e.g.,
Err("StackTrace: expected Instance".into())) instead of panicking; update the
surrounding function signature to return a Result or propagate the error through
the existing error type so callers of the stack trace code (references: vm,
as_instance, stacktrace, StackTrace) receive a proper error instead of aborting.
baml_language/crates/baml_tests/tests/exceptions.rs (1)

3444-3464: Panic stack trace test is assertion-based rather than snapshot-based.

Unlike the other stack trace tests that use insta::assert_snapshot!, this test uses loose assertions (s.contains("Traceback") and s.contains("divider")). Consider using a snapshot assertion for consistency and to catch formatting regressions.

💡 Suggestion: Use snapshot assertion for consistency
     let result = output.result.unwrap();
-    let s = format!("{result:?}");
-    assert!(s.contains("Traceback"), "expected traceback, got: {s}");
-    assert!(s.contains("divider"), "expected divider frame, got: {s}");
+    insta::assert_snapshot!(format!("{result:?}"), `@r`#"String("Traceback (most recent call last):\n  File \"test.baml\", line 7, in user.main\n  File \"test.baml\", line 3, in user.divider")"#);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/baml_tests/tests/exceptions.rs` around lines 3444 -
3464, The test uses loose assertions (s.contains("Traceback") /
s.contains("divider")) instead of a snapshot; change the assertions to use
insta::assert_snapshot! so the panic stack trace is snapshot-tested like the
others: capture the stack output string (variable s) and call
insta::assert_snapshot!(s) (or assert_snapshot!(s, @"...") if an inline expected
is needed), replacing the contains checks; update the test that currently
references risky and main and the output.bytecode snapshot block to include this
new snapshot assertion to ensure formatting/regressions are caught.
baml_language/crates/bex_vm/src/vm.rs (1)

1159-1176: Extract trace collection into one helper.

Line 1162 starts a second inlined Vec<ErrorLocation> builder that already diverges from stack_trace() above on error handling (filter_map(...ok()?) here vs unwrap_or_default() there). That makes it easy for caught catch (e, st) traces and the public stack_trace() API to drift apart on the next traceback change. A shared helper would keep file/line/function resolution consistent in one place.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/bex_vm/src/vm.rs` around lines 1159 - 1176, Extract the
inline Vec<ErrorLocation> builder into a single helper method (e.g.,
collect_stack_trace(&self) -> Vec<ErrorLocation>) and have both the inlined
unwind code and the existing stack_trace() call that helper so resolution of
function/file/line is centralized; implement the helper using the same
error-handling semantics currently used by stack_trace() (preserve
unwrap_or_default() behavior rather than filter_map(...ok()?)) and use the
existing symbols (self.frames, self.get_object, ErrorLocation,
func.bytecode.source_line_for_pc, func.name, func.source_file, func.span,
stack_trace()) so both call sites produce identical trace output.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@baml_language/crates/baml_compiler2_tir/src/builder.rs`:
- Around line 1737-1740: The code only handles Pattern::Binding when inserting
locals for the stack-trace binding; add handling for Pattern::TypedBinding as
well so declarations like catch (e, st: StackTrace) register the name for
resolution. Locate the match/if around body.patterns[st_binding] that currently
checks `if let baml_compiler2_ast::Pattern::Binding(name) =
&body.patterns[st_binding] { self.locals.insert(name.clone(), st_ty); }` and
extend it to also match `baml_compiler2_ast::Pattern::TypedBinding(name, _)` (or
otherwise extract the identifier from TypedBinding) and insert the same `st_ty`
into `self.locals`, mirroring the existing Binding behavior used for error
binding handling.

---

Duplicate comments:
In `@baml_language/crates/baml_compiler2_mir/src/lower.rs`:
- Around line 963-967: lower_lambda() constructs a fresh MirBuilder for closures
(via MirBuilder::new / self.builder) but never seeds its span, leaving
MirFunction::span empty for lambdas; after creating the new builder in
lower_lambda(), call self.span_for_expr(expr_id) (or otherwise obtain the
closure expression span) and pass it to self.builder.set_span(...) so the
newly-built MirFunction has its span populated for closure frames.

---

Nitpick comments:
In `@baml_language/crates/baml_compiler_parser/src/parser.rs`:
- Around line 3119-3125: Add parser-level Rust unit tests that assert the CST
shape and recovery for the new CATCH_STACK_TRACE_BINDING branch: write one
positive test feeding "catch (e, st)" and asserting that the CST includes a
CATCH_STACK_TRACE_BINDING node created by p.with_node (and that the second
binding is a Word token), and one malformed test feeding "catch (e,)" that
verifies recovery produces a CATCH_STACK_TRACE_BINDING node (or an expected
error node) rather than panicking; place these tests alongside the parser unit
tests in the same crate and use the existing parser test helpers/assertions to
validate node kinds and token positions.

In `@baml_language/crates/baml_compiler_syntax/src/ast.rs`:
- Around line 2791-2796: Add focused Rust unit tests to cover the new AST API
stack_trace_binding(): write one test that parses a catch clause "catch (e)" and
asserts stack_trace_binding() returns None, and another that parses "catch (e,
st)" and asserts stack_trace_binding() returns Some node whose kind() equals
SyntaxKind::CATCH_STACK_TRACE_BINDING; place these as unit tests in the same
crate (near ast.rs tests) and use existing parser/helper utilities used
elsewhere in the crate to obtain the catch clause SyntaxNode and call
stack_trace_binding() for verification.

In `@baml_language/crates/baml_tests/tests/exceptions.rs`:
- Around line 3444-3464: The test uses loose assertions (s.contains("Traceback")
/ s.contains("divider")) instead of a snapshot; change the assertions to use
insta::assert_snapshot! so the panic stack trace is snapshot-tested like the
others: capture the stack output string (variable s) and call
insta::assert_snapshot!(s) (or assert_snapshot!(s, @"...") if an inline expected
is needed), replacing the contains checks; update the test that currently
references risky and main and the output.bytecode snapshot block to include this
new snapshot assertion to ensure formatting/regressions are caught.

In `@baml_language/crates/bex_vm/src/package_baml/stack_trace.rs`:
- Around line 12-14: Replace the panic-causing expect on
vm.as_instance(stacktrace) with defensive error handling: call
vm.as_instance(stacktrace) and if it returns Some(instance) continue, otherwise
return or propagate an Err(String) (e.g., Err("StackTrace: expected
Instance".into())) instead of panicking; update the surrounding function
signature to return a Result or propagate the error through the existing error
type so callers of the stack trace code (references: vm, as_instance,
stacktrace, StackTrace) receive a proper error instead of aborting.

In `@baml_language/crates/bex_vm/src/vm.rs`:
- Around line 1159-1176: Extract the inline Vec<ErrorLocation> builder into a
single helper method (e.g., collect_stack_trace(&self) -> Vec<ErrorLocation>)
and have both the inlined unwind code and the existing stack_trace() call that
helper so resolution of function/file/line is centralized; implement the helper
using the same error-handling semantics currently used by stack_trace()
(preserve unwrap_or_default() behavior rather than filter_map(...ok()?)) and use
the existing symbols (self.frames, self.get_object, ErrorLocation,
func.bytecode.source_line_for_pc, func.name, func.source_file, func.span,
stack_trace()) so both call sites produce identical trace output.
🪄 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: 7cbfb8c6-3570-4d1b-9041-09b3041cc4e7

📥 Commits

Reviewing files that changed from the base of the PR and between d28ff29 and 94e6141.

⛔ Files ignored due to path filters (2)
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded.snap is excluded by !**/*.snap
  • baml_language/crates/baml_tests/tests/bytecode_format/snapshots/bytecode_format__bytecode_display_expanded_unoptimized.snap is excluded by !**/*.snap
📒 Files selected for processing (24)
  • baml_language/crates/baml_builtins2/baml_std/baml/ns_errors/stack_trace.baml
  • baml_language/crates/baml_builtins2/src/lib.rs
  • 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_emit/src/emit.rs
  • baml_language/crates/baml_compiler2_emit/src/lib.rs
  • baml_language/crates/baml_compiler2_hir/src/builder.rs
  • baml_language/crates/baml_compiler2_mir/src/ir.rs
  • baml_language/crates/baml_compiler2_mir/src/lower.rs
  • baml_language/crates/baml_compiler2_tir/src/builder.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_tests/tests/.env.rs.pending-snap
  • baml_language/crates/baml_tests/tests/.exceptions.rs.pending-snap
  • baml_language/crates/baml_tests/tests/exceptions.rs
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/crates/bex_vm/src/errors.rs
  • baml_language/crates/bex_vm/src/package_baml/mod.rs
  • baml_language/crates/bex_vm/src/package_baml/stack_trace.rs
  • baml_language/crates/bex_vm/src/types.rs
  • baml_language/crates/bex_vm/src/vm.rs
  • baml_language/crates/bex_vm_types/src/bytecode.rs
  • baml_language/crates/bex_vm_types/src/types.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • baml_language/crates/bex_vm_types/src/types.rs
  • baml_language/crates/bex_vm/src/package_baml/mod.rs
  • baml_language/crates/bex_vm/src/types.rs
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/crates/baml_compiler2_emit/src/emit.rs

Comment thread baml_language/crates/baml_compiler2_tir/src/builder.rs Outdated
…snapshots

Delete .pending-snap files and properly accept inline snapshots via
cargo insta. Update stack trace test assertions to pretty-print the
actual string instead of using Debug formatting.
… formatting

Remove the dead StackTrace struct and vm.stack_trace() method from
bex_vm. Extract a shared format_traceback() function in errors.rs
that both bex_engine::format_unhandled_throw and the BAML
StackTrace.to_string() impl now delegate to.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
baml_language/crates/bex_vm/src/vm.rs (1)

1048-1049: Stale dead-code annotation/comment should be removed.

alloc_stack_trace is now actively used (Line 1185), so #[allow(dead_code)] and the “Phase 5” note are outdated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/bex_vm/src/vm.rs` around lines 1048 - 1049, Remove the
stale dead-code suppression and outdated comment from the alloc_stack_trace
function: delete the #[allow(dead_code)] attribute and the “Phase 5 when catch
(e, st) is wired up.” note, since alloc_stack_trace is actively used (see usage
around alloc_stack_trace call sites); just leave the pub(crate) fn
alloc_stack_trace(&mut self, trace: &[ErrorLocation]) -> Value signature and
body unchanged.
baml_language/crates/bex_vm/src/errors.rs (1)

171-191: Add unit tests for traceback formatting edge cases.

Please add crate-local unit tests for (1) empty iterator output and (2) multi-frame ordering/format stability.

As per coding guidelines "**/*.rs: Prefer writing Rust unit tests over integration tests where possible".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@baml_language/crates/bex_vm/src/errors.rs` around lines 171 - 191, Add
crate-local unit tests in the same file to cover format_traceback: create a
#[cfg(test)] mod tests with two tests—one that calls format_traceback with an
empty iterator and asserts it returns an empty String, and another that supplies
a multi-frame iterator (e.g., vec![("test.baml", 3, "user.inner"), ("test.baml",
7, "user.main")].into_iter()) and asserts the exact Python-style multiline
output and ordering (starts with "Traceback (most recent call last):" then each
"  File \"...\", line ..., in ..." line in the given order) using assert_eq! to
ensure format stability for format_traceback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@baml_language/crates/bex_vm/src/errors.rs`:
- Around line 171-191: Add crate-local unit tests in the same file to cover
format_traceback: create a #[cfg(test)] mod tests with two tests—one that calls
format_traceback with an empty iterator and asserts it returns an empty String,
and another that supplies a multi-frame iterator (e.g., vec![("test.baml", 3,
"user.inner"), ("test.baml", 7, "user.main")].into_iter()) and asserts the exact
Python-style multiline output and ordering (starts with "Traceback (most recent
call last):" then each "  File \"...\", line ..., in ..." line in the given
order) using assert_eq! to ensure format stability for format_traceback.

In `@baml_language/crates/bex_vm/src/vm.rs`:
- Around line 1048-1049: Remove the stale dead-code suppression and outdated
comment from the alloc_stack_trace function: delete the #[allow(dead_code)]
attribute and the “Phase 5 when catch (e, st) is wired up.” note, since
alloc_stack_trace is actively used (see usage around alloc_stack_trace call
sites); just leave the pub(crate) fn alloc_stack_trace(&mut self, trace:
&[ErrorLocation]) -> Value signature and body unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 3a9b2798-0274-49d7-a531-0dfd4236e2c2

📥 Commits

Reviewing files that changed from the base of the PR and between b3a578e and 08e5336.

📒 Files selected for processing (5)
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/crates/bex_vm/src/errors.rs
  • baml_language/crates/bex_vm/src/lib.rs
  • baml_language/crates/bex_vm/src/package_baml/stack_trace.rs
  • baml_language/crates/bex_vm/src/vm.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • baml_language/crates/bex_vm/src/lib.rs
  • baml_language/crates/bex_engine/src/lib.rs
  • baml_language/crates/bex_vm/src/package_baml/stack_trace.rs

…ndings

- Rename ErrorLocation to StackFrame to match the BAML class name
- Remove stale #[allow(dead_code)] on alloc_stack_trace (now actively used)
- Populate source_file for compiled lambdas so closure frames show
  filenames in tracebacks
- Handle TypedBinding pattern for stack trace local in TIR builder
  so catch (e, st: StackTrace) works
…stead of raw indices

The $init function was built by pushing instructions directly into a
Vec, bypassing the Emitter that populates the parallel meta vector.
This caused store_global/call to display as ?141 instead of the
fully-qualified binding name. Now we build a parallel init_meta vec
with Callable, Global, and Const metadata for each instruction.
Windows CI checks out files with CRLF, which causes include_str! to
embed CRLF content for builtin .baml files. This shifts byte offsets
in build_line_starts, producing different source line numbers in
bytecode snapshots. Force LF so all platforms get identical content.
@antoniosarosi antoniosarosi enabled auto-merge April 12, 2026 19:31
@antoniosarosi antoniosarosi changed the title feat(bex_vm): capture stack traces in thrown errors with source line numbers Capture stack traces in thrown errors with source line numbers Apr 12, 2026
@antoniosarosi antoniosarosi added this pull request to the merge queue Apr 12, 2026
Merged via the queue into canary with commit 2f99bbc Apr 12, 2026
42 checks passed
@antoniosarosi antoniosarosi deleted the antonio/add-stack-trace-api-to-error-handling branch April 12, 2026 19:55
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