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

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.

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