Skip to content

[wasm][R2R] GC hole: on-frame GC locals are not reported pinned, so operand-stack copies go stale after a compacting GC #131373

Description

@lewing

Description

Wasm does not allow outside access to the wasm operand stack, so a GC reference loaded from its linear-stack home onto the operand stack is a copy the GC can neither see nor update. If the object is relocated at a safepoint, the GC fixes up the home slot but the pushed copy keeps the stale pre-move address, and the consumer dereferences it.

The wasm ABI already specifies the model that prevents this — docs/design/coreclr/botr/clr-abi.md, "GC References at Call Sites":

…all GC references live after the call (and all untracked GC references, which are effectively always live) must be saved to the linear stack. These GC references will be reported as pinned to the GC so that if they normally live in Wasm locals those locals do not need to be updated after the call.

GC-typed locals already satisfy the "saved to the linear stack" half: they are denied wasm locals via DoNotEnregisterReason::WasmGCVisibility (regallocwasm.cpp) and homed on the frame. But they are not reported pinned. In gcMakeRegPtrTable they are encoded GC_SLOT_UNTRACKED, and GC_SLOT_PINNED is only added when varDsc->lvPinned is set — which ordinary GC locals never set. So the referent moves, and any copy of it on the operand stack goes stale.

Repro shape

A store through a byref parameter, where the value comes from an allocating call:

static void StoreThroughByref(ref object slot, int i)
{
    slot = Allocate(i);   // STOREIND(LCL_VAR slot, CALL Allocate)
}

slot is pushed onto the operand stack, Allocate is the safepoint, and the store then writes through the stale address — leaving the caller's field null.

Two nearby shapes do not reproduce, which is worth knowing before writing a test:

  • A class field store. obj.Field = Call() lowers to STOREIND(ADD(LCL_VAR obj, offset), CALL) because the field is at a non-zero offset (the method table pointer occupies 0). That byref ADD has no linear-stack home, so fgWasmSpillRefs already spills it.
  • A call argument. fgMorphArgs spills an earlier argument to a temp when a later one contains a call, so the load happens after the safepoint.

Reproducing also requires allocation churn around the collection: a compacting collect on a small unfragmented heap has nothing to relocate, so the pushed copy stays accidentally valid.

Observed impact

Intermittent NullReferenceException in RuntimeCustomAttributeData..ctor and similar reflection paths during xUnit discovery under wasm R2R. Instrumenting the JIT showed the affected population is broad: 13,018 GC-ref values held across calls in 5,167 distinct framework methods.

Expected

On-frame GC slots should be reported pinned on wasm, as the ABI specifies. This is a GC-info change only — those slots are already reported and scanned as GC roots, so it adds no zero-initialization requirement and emits no code.

Note that fgWasmSpillRefs remains necessary and complementary: refs with no frame home (call results, indirections, computed byrefs) cannot be covered by pinning and still need spilling.

Note

This issue description was drafted with GitHub Copilot.

Metadata

Metadata

Assignees

Labels

arch-wasmWebAssembly architecturearea-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIin-prThere is an active PR which will close this issue when it is merged

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions