Skip to content

gc: a closure's captured this is stored in an alloca that is not shadow-bound (methods bind theirs) — same family as #7055 #7066

Closed

Description

@proggeramlug

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

  1. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    Re-verified on main = d4ac65f21. Fixed — closing as a duplicate of #7208.

    This and #7208 are the same defect found twice: codegen/closure.rs stores the captured this (and new.target) into a plain entry alloca with no js_shadow_slot_bind, while codegen/method.rs binds its equivalent. Your method.rs:337 vs closure.rs:701 diff is exactly the asymmetry #7208 restated as enable_shadow_frame(m.len() + 1) vs enable_shadow_frame(m.len()) — the +1 is the this slot.

    Fixed by #7216 (dd83c7e92): closure.rs now reserves one index per captured this / new.target and 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:

    result
    at 1679e22b4 exit=139 3/3 under polls; bad 4 on the evacuating arm
    d4ac65f21 bad 0, 10/10, byte-exact vs node 26.5.1

    Compiled 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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