Skip to content

Panic in GC with guest-debug enabled #14845

Open

Description

@alexcrichton

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
    trap raise libcall (crates/cranelift/src/trap.rs). All other libcalls
    are emitted without tags. That includes gc_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) calls gc_refs_in_frame
    (crates/wasmtime/src/runtime/debug.rs:839). That looks up the return
    address with FrameTable::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 the i32 operand
    of struct.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

  1. alexcrichton commented on Oct 19, 2026

    @alexcrichton
    MemberAuthor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    wasm-proposal:gcIssues with the implementation of the gc wasm proposalwasmtime:debuggingIssues related to debugging of JIT'ed code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions