Repository navigation
Description
Summary
A closure that captures this stores the receiver into an entry alloca that is not bound to a shadow slot, so it is invisible to precise root enumeration. Methods bind theirs. The asymmetry is direct:
crates/perry-codegen/src/codegen/method.rs:337 — bound:
let this_slot = blk.alloca(DOUBLE);
blk.store(DOUBLE, "%this_arg", &this_slot);
if super::helpers::shadow_stack_enabled() {
blk.call_void(
"js_shadow_slot_bind",
&[(I32, &this_shadow_slot_idx.to_string()), (PTR, &this_slot)],
);
}crates/perry-codegen/src/codegen/closure.rs:701 — not bound:
let this_stack = if captures_this || enclosing_class.is_some() {
let this_cap_idx = (auto_captures.len() + usize::from(captures_new_target)) as u32;
let blk = lf.block_mut(0).unwrap();
let slot = blk.alloca(DOUBLE);
if captures_this {
let bits = blk.call(I64, "js_closure_get_capture_bits",
&[(I64, "%this_closure"), (I32, &idx_str)]);
let v = blk.bitcast_i64_to_double(&bits);
blk.store(DOUBLE, &v, &slot);
} else {
blk.store(DOUBLE, "0.0", &slot);
}
vec![slot]There is no js_shadow_slot_bind for this slot anywhere in closure.rs — the two calls in that file (lines 612, 824) are for other slots.
Why it matters
The shipped default runs an evacuating young collection at loop back-edge polls (js_gc_loop_safepoint) with precise roots and no conservative native-stack scan. A receiver read once into an unbound alloca and then used after a collection is exactly the shape of #7055, which was fixed by giving %this_closure a shadow-bound home.
This is the same defect one slot over: the value is loaded once at entry, lives in a location the collector does not rewrite, and is read later. If the receiver is relocated while the closure body runs, that alloca holds a from-space pointer.
Status: verified statically, no reproducer
Found by the agent that fixed #7055, which correctly declined to change it without a reproducer. I verified the asymmetry by reading origin/main — the two code paths are as quoted.
What is NOT established: that a TypeScript program can actually reach it. That is the first thing to determine. Candidate shape — an arrow function capturing this, whose enclosing receiver is young and relocatable, executing a loop that allocates:
class C {
n = 0;
run() {
const step = () => { this.n = this.n + 1; }; // captures `this`
for (let i = 0; i < 5000; i++) { const o = { i }; step(); }
return this.n;
}
}If it does not reproduce, establish why — e.g. the receiver is already tenured by the time the closure runs, or the capture is re-read per access rather than cached — and record that, because "unreachable today" is a property that a later change can quietly remove.
Acceptance
Same bar as #7055: reproduce against an unfixed build first, then a regression test sabotage-verified in both directions. #7055's test sweeps PERRY_GC_SCAVENGE_NURSERY_MB across arms from a single binary rather than trusting one cap, because a single cap is a coin flip on a given host — the unfixed compiler there failed at 1, 2 and 8 MB but not all of them.
Note also #7055's test hazard, which applies here: an exported PERRY_GEN_GC=0 in the environment would make the whole suite pass against an unfixed compiler.
Relevant to closing out precise-root completeness, which is the precondition for removing the conservative stack scanner.
Activity
proggeramlug commentedon Aug 2, 2026
Re-verified on
main=d4ac65f21. Fixed — closing as a duplicate of #7208.This and #7208 are the same defect found twice:
codegen/closure.rsstores the capturedthis(andnew.target) into a plain entry alloca with nojs_shadow_slot_bind, whilecodegen/method.rsbinds its equivalent. Yourmethod.rs:337vsclosure.rs:701diff is exactly the asymmetry #7208 restated asenable_shadow_frame(m.len() + 1)vsenable_shadow_frame(m.len())— the+1is thethisslot.Fixed by #7216 (
dd83c7e92):closure.rsnow reserves one index per capturedthis/new.targetand binds each in the entry block immediately after its store.Evidence
This issue was filed "verified statically, no reproducer". One exists now —
test-files/test_gap_gc_closure_this_capture_rooting.ts— and it was a real runtime failure, not just a static hazard:1679e22b4exit=1393/3 under polls;bad 4on the evacuating armd4ac65f21bad 0, 10/10, byte-exact vs node 26.5.1Compiled with no compile-time GC env, run on the allocation-point arm (
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off). Movement confirmed: 5 copying minors, 17 325 objects copied.Part of the sweep in #7262.