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

Closed

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.

Activity

  1. added
    arch-wasmWebAssembly architecture
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Jul 25, 2026
  2. dotnet-policy-service commented on Jul 25, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
    See info in area-owners.md if you want to be subscribed.

  3. dotnet-policy-service commented on Jul 25, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
    See info in area-owners.md if you want to be subscribed.

  4. self-assigned this
    on Jul 25, 2026
  5. added
    in-prThere is an active PR which will close this issue when it is merged
    and removed
    untriagedNew issue has not been triaged by the area owner
    on Jul 25, 2026
  6. changed the title [-][wasm][R2R] GC hole: ref loaded from a local and held across a call is not spilled, goes stale after a compacting GC[/-] [+][wasm][R2R] GC hole: on-frame GC locals are not reported pinned, so operand-stack copies go stale after a compacting GC[/+] on Jul 26, 2026
  7. added a commit that references this issue on Jul 27, 2026
    6b6ef2e
  8. locked and limited conversation to collaborators on Aug 27, 2026
  9. added a commit that references this issue on Aug 28, 2026
    fff138c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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