fitzgen commented on issue #14571:
<details><summary>Full LLM report</summary>
Debug tags on
try_callare dropped in lowering; with inlining, guest-debug breakpoints fire unrequested and frames are lost
Date 2026-10-05 Wasmtime commit 73b04cff3317d1e308866eb24359483ac6116669(main)Host macOS 15.8.1 (Darwin 24.6.0), aarch64-apple-darwinModel Claude Opus 5.5 ( claude-opus-5-5)Component cranelift/codegen/src/machinst/lower.rs(root cause); made visible bycranelift/codegen/src/inline.rsexception-table fixupsFeatures Config::guest_debug(true)+Config::compiler_inlining(..)+ exceptions (a combination Wasmtime allows and tests intests/all/debug.rs::breakpoints_in_inlined_code)Severity Low to medium. The debugger receives breakpoint events nobody requested and loses frames and locals. There is no sandbox impact. Summary
The CLIF verifier accepts debug tags on any call,
try_callincluded
(cranelift/codegen/src/verifier/mod.rs:2058-2071). Lowering, however, only
forwards them to VCode for non-branch instructions (lower.rs:993-1001, in
the per-block instruction loop). The branch path (lower.rs:~1274-1296)
forwards atry_call's user stack-map entries (:1284) but never its
debug_tags. Tags on atry_callare therefore silently dropped before they
reach theMachBuffer.Without inlining, Wasmtime's guest-debug breakpoint hooks are always plain
patchablecalls (crates/cranelift/src/func_environ.rs:5307-5311), so they
keep their tags. Wasm calls insidetry_tabledo become taggedtry_calls
throughattach_tags(func_environ.rs:~2613), and those tags are dropped
too. No visible symptom from that alone was found; see the scope note below.Inlining is what makes the bug visible. When a callee is inlined at a
try_callsite (a call insidetry_table),
fixup_inlined_call_exception_tables(inline.rs:672-702) rewrites every
inlinedcallinto atry_callthat carries the caller's handlers. That
includes the callee's patchable breakpoint calls. Their tags are then lost,
which causes two problems.
CompiledFunction::finalize_breakpoints
(crates/cranelift/src/compiled_function.rs:100-137) pairs patchable call
sites with debug tags to produce breakpoint patch records. The untagged sites
get no record, socompiler.rs:~800-820never turns them into NOPs:
- They stay live
bl wasmtime_patchable_builtin_breakpointinstructions.- They fire on every execution, even when no breakpoint is set.
They also get no frame-table program point. Frame inspection at, or below,
these sites falls back to a neighbouring program point. The inlined callee's
virtual frame and its locals disappear.
wasmtime objdumpof a guest-debug + inlining build confirms this. The inlined
breakpoint calls areblto the breakpoint builtin with exception-handler
annotations, but have no "breakpoint patch" or "debug frame state"
annotation. Every non-inlined breakpoint site is anopwith a patch record.Reproduction
Cranelift level
$ target/debug/clif-util test reports/020-trycall-debug-tags-dropped/tags.clif > ; ^-- debug @ Post: [StackSlot(ss0), User(1), User(2)] <- plain `call`: tags kept Matched #0: ... Missed #1: \bdebug @ Post: \[StackSlot\(ss0\), User\(3\), User\(4\)\] <- `try_call`: tags dropped Error: 1 failure
tags.clifmakes two calls to the same patchable callee:
<ss0, 1, 2> calland<ss0, 3, 4> try_call.Wasmtime level
wasm-repro/is a Rust crate using the public API. It enables
guest_debug(true)and exceptions, installs aDebugHandlerthat logs every
event plus the locals of each frame, and sets no breakpoints. It then runs
the module below twice: withInlining::No, and withInlining::Yesand
aggressive thresholds.(module (tag $t (param i32)) (func (export "main") (result i32) (block $b (result i32) (try_table (catch $t $b) call $thrower) i32.const 0)) (func $thrower (local $i i32) (local.set $i (i32.const 100)) (throw $t (i32.const 42))))(The crate's module also exports
plainandtrycatch, so function indices
below refer to that larger module.)$ cargo run --manifest-path reports/020-trycall-debug-tags-dropped/wasm-repro/Cargo.toml No: result=42 Exception @func4 pc=0x7f | frame locals=["I32(100)"] | frame locals=[] Yes: result=42 Breakpoint @func0 pc=0x4d | frame locals=[] Breakpoint @func0 pc=0x4d | frame locals=[] Breakpoint @func0 pc=0x4d | frame locals=[] Breakpoint @func0 pc=0x4d | frame locals=[] Exception @func0 pc=0x4d | frame locals=[]What changes when inlining is on:
- Spurious events: four
Breakpointevents fire that nobody requested.- Lost frame: the
Exceptionevent no longer shows$thrower's frame,
so its$i = 100is gone.
FUNC=trycatchis a non-throwing variant. It also produces four spurious
Breakpointevents with inlining and none without.The expected behaviour is the same events as without inlining: one
Exception, with an inlined virtual frame for$thrower.
breakpoints_in_inlined_codeshows that inlined virtual frames work for
plaincallsites.The in-tree test
tests/all/debug.rs::caught_exception_eventsfails
("Incorrect event") for the same reason when the defaultTunablesforce
aggressive inlining.Suggested fix
Primary: in the branch-lowering path of
lower.rs(next to the
stack-map forwarding at:1284), forwardself.f.debug_tags.get(branch)to
the emitted VCode instruction the same way the non-branch path does. Then
try_calltags reach theMachBuffer, and patch records and frame-table
entries are produced.Additionally, optionally: have
fixup_inlined_call_exception_tables
leavepatchablecalls as plaincalls. The breakpoint trampoline never
throws into Wasm handlers, so it does not need an exception table.Tests: add a
test compilefiletest with tags on atry_call, and a
caught_exception_eventsvariant withInlining::Yesto
tests/all/debug.rs.Scope note
The tags are dropped for every tagged
try_call, including non-inlined Wasm
calls insidetry_table. A single-step probe of that case
(reports/2026-10-05-audit/inlining/dbg2/) still showed a correct parent PC
and operand stack. The neighbouring breakpointcall's program point covers
the return address there. So no visible symptom was found without inlining,
though correctness in that case depends on that coincidence.</details>
fitzgen opened issue #14571:
Lowercopies an instruction's debug tags to VCode only for non-branch
instructions. The branch path forwards atry_call's user stack maps but not
its debug tags, so the tags are silently dropped.This becomes visible with guest debugging plus inlining. Inlining a callee
into a call insidetry_tableturns the callee's patchable breakpoint calls
intotry_calls. Those calls lose their tags, so they get no breakpoint
patch record and are never NOP'd out. As a result:
Breakpointevents fire even though no breakpoint was set;- the inlined callee's frame is missing from the debugger's view.
Test Case
.clif:test compile target aarch64 function %f(i64) tail { ss0 = explicit_slot 8, key = 0 sig0 = (i64) preserve_all fn0 = colocated patchable %bp sig0 block0(v0: i64): <ss0, 1, 2> call fn0(v0) <ss0, 3, 4> try_call fn0(v0), sig0, block1, [ default: block2 ] block1: return block2: return } ; check: debug @ Post: [StackSlot(ss0), User(1), User(2)] ; check: debug @ Post: [StackSlot(ss0), User(3), User(4)]Wasmtime embedding:
<details>
#[tokio::test] #[cfg_attr(miri, ignore)] async fn no_spurious_breakpoints_in_inlined_try_table_callee() -> wasmtime::Result<()> { #[derive(Clone)] struct Recorder(Arc<Mutex<Vec<&'static str>>>); impl DebugHandler for Recorder { type Data = (); fn handle( &self, _store: StoreContextMut<'_, ()>, event: DebugEvent<'_>, ) -> impl Future<Output = ()> + Send { self.0.lock().unwrap().push(match event { DebugEvent::Breakpoint => "breakpoint", DebugEvent::Exception(_) => "exception", _ => "other", }); async {} } } let mut config = Config::default(); config .guest_debug(true) .wasm_exceptions(true) .compiler_inlining(Inlining::Yes); let engine = Engine::new(&config)?; let module = Module::new( &engine, r#" (module (tag $t (param i32)) (func (export "main") (result i32) (block $b (result i32) (try_table (catch $t $b) call $thrower) i32.const 0)) (func $thrower (throw $t (i32.const 42)))) "#, )?; let mut store = Store::new(&engine, ()); let events = Arc::new(Mutex::new(vec![])); store.set_debug_handler(Recorder(events.clone())); // No breakpoints are set, so only the exception should be reported. let instance = Instance::new_async(&mut store, &module, &[]).await?; let main = instance.get_typed_func::<(), i32>(&mut store, "main")?; assert_eq!(main.call_async(&mut store, ()).await?, 42); assert_eq!(*events.lock().unwrap(), ["exception"]); Ok(()) }</details>
Steps to Reproduce
clif-util test test.clif- Add the Rust test to
tests/all/debug.rs, then run
cargo test --test all -- no_spurious_breakpoints_in_inlined_try_table_calleeExpected Results
.clif: both checks match, i.e. thetry_callkeeps its tags.- Rust: the test passes. With no breakpoints set, only the exception
event is reported, as when inlining is disabled.Actual Results
.clif:
Missed #1: \bdebug @ Post: \[StackSlot\(ss0\), User\(3\), User\(4\)\]Rust: two
Breakpointevents fire even though no breakpoints were set:
assertion `left == right` failed left: ["breakpoint", "breakpoint", "exception"] right: ["exception"]Versions and Environment
Wasmtime version or commit:
73b04cff33Operating system: macOS 15.8.1
Architecture: aarch64
fitzgen added the bug label to Issue #14571.
fitzgen added the wasmtime:debugging label to Issue #14571.
fitzgen added the cranelift:area:clif label to Issue #14571.
Last updated: Oct 11 2026 at 04:10 UTC