alexcrichton opened issue #14845:
This input:
(module (type $s (struct (field i32))) (func (export "main") (param $n i32) (result anyref) (loop $l (drop (struct.new $s (i32.const 0x30))) (br_if $l (local.tee $n (i32.sub (local.get $n) (i32.const 1))))) (ref.null any)))fails with:
$ cargo run run -D guest-debug --invoke main ../wasmtime-vuln-triage/bug-in-triage/0163-gc-guest-debug-cold-libcall-wrong-stack-shape/foo.wat 10000 Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.10s Running `target/debug/wasmtime run -D guest-debug --invoke main ../wasmtime-vuln-triage/bug-in-triage/0163-gc-guest-debug-cold-libcall-wrong-stack-shape/foo.wat 10000` warning: using `--invoke` with a function that takes arguments is experimental and may break in the future thread 'main' (408509) panicked at crates/wasmtime/src/runtime/vm/gc/enabled/copying.rs:1044:13: assertion failed: self.heap.is_in_idle_space(old_index) note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceAn LLM summary, if helpful, is:
<details>
With
Config::guest_debug(true)and the copying collector, a GC that
happens inside a libcall (for example thegc_alloc_rawslow path of
struct.new) traces the frame's debug state slot using the stack shape of
some earlier program point. That can make the collector treat a live
non-reference operand, such as ani32, as a GC root.Reproduction
gc-libcall.wat:(module (type $s (struct (field i32))) (func (export "main") (param $n i32) (result anyref) (loop $l (drop (struct.new $s (i32.const 0x30))) (br_if $l (local.tee $n (i32.sub (local.get $n) (i32.const 1))))) (ref.null any)))With a debug build of the CLI at bytecodealliance/wasmtime@68b32edee9 (GC,
function references and the copying collector are all on by default):$ wasmtime run -D guest-debug --invoke main gc-libcall.wat 10000 thread 'main' panicked at crates/wasmtime/src/runtime/vm/gc/enabled/copying.rs:1044:13: assertion failed: self.heap.is_in_idle_space(old_index) $ wasmtime run --invoke main gc-libcall.wat 10000 <null anyref>The argument must be large enough for an allocation to trigger a GC: here
2045 iterations still pass and 2046 panic.The value that ends up treated as a root is the guest's
i32operand, so
other constants fail differently. With0x100, the 49.0.3 release CLI
aborts with a panic atcrates/environ/src/gc.rs:523:32. A release build of
mainshowed no visible effect for0x30,0x100or0x7fffffff. But it
still reads a guest-chosen value as a GC reference, so wrong results are
possible.Cause
When guest debugging is on, the frame table records a program point (a
PC plus the state slot's stack shape) only for instructions with debug
tags. Today those are Wasm-level calls (attach_tags,
crates/cranelift/src/func_environ.rs:2639), the per-operator
breakpoint (before_translate_operator,func_environ.rs:5324) and the
trapraiselibcall (crates/cranelift/src/trap.rs). All other libcalls
are emitted without tags. That includesgc_alloc_raw
(emit_gc_raw_alloc,crates/cranelift/src/func_environ/gc.rs:238),
which is in a cold block after the function body.During a GC,
trace_wasm_stack_frame
(crates/wasmtime/src/runtime/store/gc.rs:760) callsgc_refs_in_frame
(crates/wasmtime/src/runtime/debug.rs:839). That looks up the return
address withFrameTable::find_program_point(pc, FrameInstPos::Post)
(crates/environ/src/frame_table.rs:192).An untagged libcall's return address has no entry, so the binary search
falls back to the nearest preceding program point (Err(idx) if idx > 0 => idx - 1,frame_table.rs:203). Since the libcall is in the same
function, the function-start check doesn't reject that point.In the reproducer, that point is the function's final
end, whose stack
shape is[anyref]. But the slot's stack entry 0 holds thei32operand
ofstruct.new, which was flushed there before the operator's breakpoint.
The copying collector then tries to forward it.The normal stack maps aren't affected; this is only about the debug state
slot. Any libcall that can GC has the same problem, not justgc_alloc_raw:
for example other allocation paths, explicit collections, table or array
growth, and epoch or fuel yields that run host code.Possible fix
One option is to tag every call that doesn't already have tags with the
tags of the breakpoint of the operator it was emitted for. The state slot's
operand stack is only flushed between operators (update_state_slot_stack,
func_environ.rs:1358, called fromafter_translate_operator). So for
the whole of an operator's translation, the slot has the shape recorded at
that operator's breakpoint. For example:// In `before_translate_operator`, after setting the breakpoint's tags: self.breakpoints.insert(builder.srcloc(), inst); // At the end of the function (e.g. from `finish_debug_metadata`), for each // `call`, `call_indirect`, `try_call` or `try_call_indirect` without tags: if let Some(bp) = self.breakpoints.get(&func.srcloc(inst)) { func.debug_tags.clone_tags(*bp, inst); }With that, the reproducer passes.
tests/allandtests/disasalso pass;
tests/disas/debug-exceptions.watonly gainsdebug frame statelines on
libcalls.Making
find_program_point(.., Post)require an exact match, so the
frame contributes no debug roots, isn't enough with a moving collector. The
state slot's references wouldn't be updated, and the debugger would later
read stale values.Open question: calls emitted before the first operator (the function-entry
stack-limit check and fuel/epoch setup) have no breakpoint to copy tags
from, and would stay untagged. If one of them can GC while the state slot
holds reference-typed parameters, it needs its own program point. I haven't
checked this.</details>
alexcrichton added the wasmtime:debugging label to Issue #14845.
alexcrichton added the wasm-proposal:gc label to Issue #14845.
alexcrichton commented on issue #14845:
cc @cfallin
Last updated: Oct 31 2026 at 23:03 UTC