Repository navigation
Description
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.
Activity
proggeramlug commentedon Aug 1, 2026
The enumeration this issue asks for is done, and the named site is fixed in #7207.
The enumeration
140 bare-alloca sites across
crates/perry-codegenatorigin/main(3a983ca6a) — 63alloca_entry, 42alloca_entry_array, 2alloca_entry_bytes_aligned, 19blk.alloca, 14 rawemit_raw. No other alloca-emitting spelling exists. Classified 23 HAZARD / 117 SAFE; the full table with per-site disposition is inchangelog.d/7207-residual-root-store-holes.md.thisslot (lower_call/new.rs:1109)this/new.targetslotscatch (e)parameter slotTwo corrections to this issue's text worth recording:
[N x i64]closure-capture staging array is SAFE, and safe past the call boundary by an explicit callee-side mechanism, not by luck: every capture value is lowered before the alloca (expr/closure.rs:80-121), the store block contains onlygep/bitcast/store, andperry-runtime/src/closure/alloc.rs:323+opens aRuntimeHandleScopeand roots every word out of the caller's buffer before it allocates. That is a per-callee contract, not a general one — the ~40 other lower-then-store args buffers rely on the same thing and were not audited; GC: the remaining unrooted-alloca hazards after #7207 — class-keys pointer caches, interleaved staging arrays, inlined-callee param slots #7210 §2/§3 covers the ones that are genuinely interleaved.ctor_result_slot's fix(codegen): close #7192's own residual root-store hole, and make the dominance checker able to fail #7198 fix holds atorigin/main, verified: it is seededTAG_UNDEFINED, and the one remaining writer (new.rs:1744, an imported constructor's return) has no collection point before the load — but only by a coincidence of guard conditions (apply_field_initializers_recursiveis gated!has_imported_ctor), not by construction.The
this_slotfixBound as a shadow slot via
expr::scalar_slot_root::root_entry_alloca(this issue's own suggestion — #6968's grow-on-demand machinery, generalized). Binding rather than routingExpr::Thisthrough a temp root is what makes it a one-line change at ~30 read sites: every reader already doesload DOUBLE, ptr <slot>, andjs_shadow_slot_bindrecordsslot_ptrs[idx] = alloca, so evacuation rewrites it in place. Gated onconstruction_runs_user_code— the same predicate that decided the instance needed a temp root — so a class with no constructor, fields or heritage keeps its previous IR exactly, frame size included.No runtime reproducer, and why
test-files/test_gap_gc_inline_ctor_this_rooting.tsis green at base, and ships saying so. The stale write is currently absorbed: the class-field inline guard readsobj_type/class_id/keys_arrayoff the stale pointer, the evacuation's forwarding record fails that guard, and the runtime fallback resolves forwarding — so the write lands on the moved object after all. That masking holds only while the from-space block still carries the forwarding record, which is exactly #7154's latency, and #7196'smprotectquarantine is the instrument that would collapse it (still open at the time of writing).So this rests on the static argument, and is gated two ways instead: a codegen regression test whose two conjuncts sabotage-check red independently, plus a new
gc_root_dominance_check.py --unrooted-allocasmode.The static gate this issue asked for
The shipped check anchors on
js_shadow_slot_bind, so a value that is never bound produces no triple — which is why #7192's checker read the corpus at 1 violation whilethis_slotwas live. The new mode reports a gc-capable alloca that no bind names, holding a value whose provenance is a heap source, loaded below a collecting call on a real CFG path; allocas whose address escapes to a call are excluded rather than guessed at.A/B on the reproducer's IR under
PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_INLINE_SHADOW_SLOT=0: 2 violations → 1, moving-minor reachable 1 → 0. The survivor isfunction.rs:521'sperry_class_keys_*cache (#7210 §1).This issue's worry that the general stale-register invariant needs
NONCOLLECTINGextending before it is actionable is correct and separate; #7206 is doing that as--stale-registers.fix(gc): close the three residual root-store holes — the PutValueSet …
ci(gc): make the moving-GC stale-root witnesses able to fail (+ #7205) (
fix(gc): split the dominance checker's heap-source predicate by movab…
proggeramlug commentedon Aug 2, 2026
Re-verified on
main=d4ac65f21. Fixed — closing, with the residual explicitly relocated.Both named instances are closed:
this_slot— fixed by fix(gc): close the three residual root-store holes — the PutValueSet key, js_object_assign_one, and the inline-ctor this slot #7207.lower_call/new.rs:1148-1164now seeds the alloca toundefinedinentry_allocasand callscrate::expr::root_entry_alloca(ctx, &this_slot), gated onconstruction_runs_user_code(the same predicate that decides the instance needs a temp root), so a class with no constructor, fields or heritage keeps its previous IR and frame size exactly. Binding it — rather than routingExpr::Thisthrough a temp root, as this issue proposed — left all ~30ctx.this_stack.last()readers untouched, because they alreadyloadfrom the alloca andjs_shadow_slot_bindrecordsslot_ptrs[idx] = alloca.this/new.targetslots — split out as GC: a closure's capturedthis/new.targetslots are unrooted allocas (closure.rs reserves no +1 slot where method.rs does) #7208 and fixed by fix(gc): bind the catch parameter and the closure this/new.target slots, and snapshot the Object.assign string source #7216. Verified green with a runtime witness:test_gap_gc_closure_this_capture_rooting10/10 byte-exact on the allocation-point arm at 5 copying minors / 17 325 objects copied.Evidence
The instrument this issue called for was built (
--unrooted-allocas, added in #7207) and now reports clean over a freshly generated corpus:with the bind-anchored mode also at 0 and
--seeded-violations 40catching 40/40, so neither zero is a parse failure.The residual is tracked, not dropped. The
[N x i64]staging-array shape in §2 of this issue iscodegen/helpers.rs:1275, still a bareemit_raw("alloca [N x double]")filled interleaved with lowering — that is #7210 §2 and it is open. The general stale-register invariant this issue asked for is likewise #7210's subject.Part of the sweep in #7262.