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.
Summary
A closure that captures
thisstores 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:crates/perry-codegen/src/codegen/closure.rs:701— not bound:There is no
js_shadow_slot_bindfor this slot anywhere inclosure.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_closurea 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: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_MBacross 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=0in 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.