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.
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_entryslot 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_sloton the inline-constructor path —crates/perry-codegen/src/lower_call/new.rsEvery
thisread inside the inlined constructor body loads from that alloca.alloca_entry(crates/perry-codegen/src/function.rs:421) emits a barealloca; 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 underPERRY_GC_MOVING_LOOP_POLLS=1— every subsequentthis.x = …writes into abandoned from-space memory.Reachability is the default, not an opt-in.
force_ctor_callrequiresclass.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 withPERRY_INLINE_CTORunset.Its sibling
ctor_result_slothad the same defect and is fixed in #7198 by never letting it carry an address (it now starts atundefined, sojs_ctor_return_overridemaps fall-through to the re-readthis). That fix does not generalise tothis_slot, which has to hold the address by construction.The fix is not local:
thisis read at arbitrary points throughctx.this_stack, so making it relocation-safe means routingExpr::Thisthrough a root rather than an alloca — a representation change forthis_stackwith its own tests, plus every other consumer (this_slot_for_err, closures capturingthis).2. The
[N x i64]closure-capture staging arraySame shape:
alloca_entry_arraystorage 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_slotand the oldctor_result_slotwere 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.pydoes not model it either: it anchors onjs_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 thesfw-registrycorpus before allowlist tuning, soNONCOLLECTINGneeds extending before it is actionable.Why it matters
Along with #7200 and #7201, this is why
PERRY_GC_MOVING_LOOP_POLLS=1cannot 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.