Stream: git-wasmtime

Topic: wasmtime / issue #14571 Debug tags on `try_call` are drop...


view this post on Zulip Wasmtime GitHub notifications bot (Oct 06 2026 at 15:08):

fitzgen commented on issue #14571:

<details><summary>Full LLM report</summary>

Debug tags on try_call are 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-darwin
Model Claude Opus 5.5 (claude-opus-5-5)
Component cranelift/codegen/src/machinst/lower.rs (root cause); made visible by cranelift/codegen/src/inline.rs exception-table fixups
Features Config::guest_debug(true) + Config::compiler_inlining(..) + exceptions (a combination Wasmtime allows and tests in tests/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_call included
(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 a try_call's user stack-map entries (:1284) but never its
debug_tags. Tags on a try_call are therefore silently dropped before they
reach the MachBuffer.

Without inlining, Wasmtime's guest-debug breakpoint hooks are always plain
patchable calls (crates/cranelift/src/func_environ.rs:5307-5311), so they
keep their tags. Wasm calls inside try_table do become tagged try_calls
through attach_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_call site (a call inside try_table),
fixup_inlined_call_exception_tables (inline.rs:672-702) rewrites every
inlined call into a try_call that 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, so compiler.rs:~800-820 never turns them into NOPs:

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 objdump of a guest-debug + inlining build confirms this. The inlined
breakpoint calls are bl to the breakpoint builtin with exception-handler
annotations, but have no "breakpoint patch" or "debug frame state"
annotation. Every non-inlined breakpoint site is a nop with 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.clif makes two calls to the same patchable callee:
<ss0, 1, 2> call and <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 a DebugHandler that logs every
event plus the locals of each frame, and sets no breakpoints. It then runs
the module below twice: with Inlining::No, and with Inlining::Yes and
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 plain and trycatch, 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:

FUNC=trycatch is a non-throwing variant. It also produces four spurious
Breakpoint events 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_code shows that inlined virtual frames work for
plain call sites.

The in-tree test tests/all/debug.rs::caught_exception_events fails
("Incorrect event") for the same reason when the default Tunables force
aggressive inlining.

Suggested fix

Scope note

The tags are dropped for every tagged try_call, including non-inlined Wasm
calls inside try_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 breakpoint call'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>

view this post on Zulip Wasmtime GitHub notifications bot (Oct 06 2026 at 15:09):

fitzgen opened issue #14571:

Lower copies an instruction's debug tags to VCode only for non-branch
instructions. The branch path forwards a try_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 inside try_table turns the callee's patchable breakpoint calls
into try_calls. Those calls lose their tags, so they get no breakpoint
patch record and are never NOP'd out. As a result:

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

Expected Results

Actual Results

Versions and Environment

Wasmtime version or commit: 73b04cff33

Operating system: macOS 15.8.1

Architecture: aarch64

view this post on Zulip Wasmtime GitHub notifications bot (Oct 06 2026 at 15:09):

fitzgen added the bug label to Issue #14571.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 06 2026 at 15:09):

fitzgen added the wasmtime:debugging label to Issue #14571.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 06 2026 at 15:09):

fitzgen added the cranelift:area:clif label to Issue #14571.


Last updated: Oct 11 2026 at 04:10 UTC