Repository navigation
Panic in GC with guest-debug enabled #14845
Description
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 backtrace
An LLM summary, if helpful, is:
Details
With Config::guest_debug(true) and the copying collector, a GC that
happens inside a libcall (for example the gc_alloc_raw slow 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 an i32, 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 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 i32 operand, so
other constants fail differently. With 0x100, the 49.0.3 release CLI
aborts with a panic at crates/environ/src/gc.rs:523:32. A release build of
main showed no visible effect for 0x30, 0x100 or 0x7fffffff. 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 just gc_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 from after_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/all and tests/disas also pass;
tests/disas/debug-exceptions.wat only gains debug frame state lines 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.
Activity
alexcrichton commentedon Oct 19, 2026
cc @cfallin