Skip to content

GC: heap values in plain alloca_entry slots are neither marked nor rewritten (inline-ctor this_slot, closure-capture staging array) #7202

Description

@proggeramlug

Summary

The third way the root-store dominance invariant breaks, and the one neither #7184 nor #7192 addresses: a heap value living in a plain alloca_entry slot that is neither a shadow slot nor a temp root, so the collector neither marks nor rewrites it.

#7184 fixed a root store whose slot index fell outside the pushed shadow frame (silently bounds-checked into a no-op). #7192 fixed a root store emitted in-frame but after a collection point. This one has no root store at all — the value is in unrooted stack memory for the whole window.

The two known instances

1. this_slot on the inline-constructor path — crates/perry-codegen/src/lower_call/new.rs

let this_slot = ctx.func.alloca_entry(DOUBLE);
ctx.block().store(DOUBLE, &obj_box, &this_slot);
ctx.this_stack.push(this_slot);

Every this read inside the inlined constructor body loads from that alloca. alloca_entry (crates/perry-codegen/src/function.rs:421) emits a bare alloca; nothing binds it to a shadow slot and nothing pushes it as a temp root. If the instance relocates part-way through the body — a field initializer that calls a function containing a loop is enough under PERRY_GC_MOVING_LOOP_POLLS=1 — every subsequent this.x = … writes into abandoned from-space memory.

Reachability is the default, not an opt-in. force_ctor_call requires class.constructor.is_some(), so any class with fields or heritage but no own constructor (class C { payload = mk() }, class C extends B {}) takes the inline path with PERRY_INLINE_CTOR unset.

Its sibling ctor_result_slot had the same defect and is fixed in #7198 by never letting it carry an address (it now starts at undefined, so js_ctor_return_override maps fall-through to the re-read this). That fix does not generalise to this_slot, which has to hold the address by construction.

The fix is not local: this is read at arbitrary points through ctx.this_stack, so making it relocation-safe means routing Expr::This through a root rather than an alloca — a representation change for this_stack with its own tests, plus every other consumer (this_slot_for_err, closures capturing this).

2. The [N x i64] closure-capture staging array

Same shape: alloca_entry_array storage holding capture values across the lowering that fills it.

Why it is hard to see

There is no crash and often no wrong answer. this_slot and the old ctor_result_slot were stale together, so the whole construction operated consistently on the from-space copy and the caller received a from-space object with all its fields present. The program is correct-looking and holds a dangling pointer — exactly why #7154's from-space scan only ever saw offenders one or more cycles after the target died, with correct layout coverage.

scripts/gc_root_dominance_check.py does not model it either: it anchors on js_shadow_slot_bind, and there is none. Extending it needs the general stale-register invariant (any register or unrooted alloca holding a heap value that is live across a collecting call and then used without being re-read from a root). A prototype on top of the shipped CFG/dominance layer reported ~1000 raw hits on the sfw-registry corpus before allowlist tuning, so NONCOLLECTING needs extending before it is actionable.

Why it matters

Along with #7200 and #7201, this is why PERRY_GC_MOVING_LOOP_POLLS=1 cannot become the default again — i.e. why #7161 cannot be reverted and #7019's minor-GC RSS win stays reverted.

Refs #7154, #7161, #7184, #7192.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions