Skip to content

gc: root a closure's own this_closure pointer on the shadow stack (#7055) - #7065

Merged
proggeramlug merged 3 commits into
mainfrom
fix/7055-relocating-minor-wrong-answer
Jul 30, 2026
Merged

gc: root a closure's own this_closure pointer on the shadow stack (#7055)#7065
proggeramlug merged 3 commits into
mainfrom
fix/7055-relocating-minor-wrong-answer

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #7055.

The defect

A closure body reaches its captured variables through
js_closure_get_capture_bits(%this_closure, idx). %this_closure is the LLVM
parameter the caller passed — a register value no root enumeration can see.

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. So when a closure is relocated while its own body is on the stack, every
root the collector knows about is rewritten and that register is not. From-space
is reset at the end of the same cycle and handed straight back to the mutator,
so the next capture access reads a different object's header:
js_closure_get_capture_bits finds idx >= real_capture_count(...) and returns
0. Pointer 0 is not a registered box, so from that instant on every
boxed-capture read in the body yields undefined and every boxed-capture write
is silently dropped (js_box_set / js_i32_box_set no-op on an unregistered
pointer).

In an async fn the casualty is the generator state machine itself. The
async-to-generator transform boxes every body local, including __gen_state, so
the state = <next> store at the end of a resumed state body went nowhere. The
following await then resumed into the state it had just finished and ran one
loop iteration twice
— folding one request's contribution into the accumulator
a second time. That is #7055's signature exactly: a deterministic checksum error
of fixed magnitude, identical at 150 / 300 / 600 / 1200 requests, present only
when copied_objects > 0.

Minimal reproducer

Down from the issue's ~60-line w5_srv_scale.ts to 11 lines. calls must be 3:

let calls = 0;
async function main(): Promise<void> {
  for (let r = 0; r < 3; r++) {
    calls = calls + 1;
    let o: any = null;
    for (let i = 0; i < 50; i++) { o = { id: i }; }
    await Promise.resolve();
  }
  console.log("calls:" + calls);
}
main();
$ perry min.ts -o min
$ PERRY_GC_SCAVENGE_NURSERY_MB=1 ./min      # before
calls:4
$ PERRY_GC_SCAVENGE_NURSERY_MB=1 ./min      # after
calls:3

It needs neither setImmediate nor JSON.stringify nor argv — only an
async fn whose loop body allocates objects (w6, the same program pushing
numbers, does not reproduce: no pointer to relocate) and a relocating minor. The
nursery cap is a pacing knob only; the issue's default-configuration failure is
the same program at 150 × 1500 rows.

Forward trace of the wrong value

Runtime instrumentation on the 11-line program, one run, in order:

[asyncstep] MOVE closure 0x4e523eb0050 -> 0x4e524f00008 promote=false cc=10
[PERRY WARN] js_box_set:     invalid box pointer 0x0 (value bits: 0x7ffd04e523eb0050)
[PERRY WARN] js_i32_box_set: invalid box pointer 0x0 (value: 1)
[PERRY WARN] js_box_get:     invalid box pointer 0x0
[asyncstep] enqueue step=0x4e524f00008 cc=10
[asyncstep] step=0x4e524f00008 space=Survivor1 cc=10 cur_cb=0x4e524f00008
  1. The state-0 body is running. Its inner allocating loop trips a poll and the
    step closure is evacuated …3eb0050 → …4f00008; every root the collector
    owns (INLINE_TRAP.current_step, CURRENT_MICROTASK_CALLBACK, TASK_QUEUE)
    is rewritten. The JS frame's %this_closure register still holds …3eb0050.
  2. From-space is reset; the mutator's very next { id: i } lands at
    …3eb0050
    (see the value bits of the first dropped box write — the old
    closure address, now an ordinary object).
  3. Every later js_closure_get_capture_bits(…3eb0050, idx) returns 0, so
    js_i32_box_set(0, 1) — the __gen_state = 1 store — is dropped.
  4. The resume comes in on the correct pointer (step=…4f00008, cur_cb
    matches), reads state as 0, and re-runs state 0.

Exactly one duplicate, because only the one body execution overlapped the move.

The fix

crates/perry-codegen/src/codegen/closure.rs: a closure body that has captures
spills %this_closure into an entry-block alloca, NaN-boxed with POINTER_TAG,
and binds it to a shadow-stack slot (reserve_shadow_slot +
js_shadow_slot_bind). expr::current_closure_ptr_value reloads the pointer
from that slot at every capture access, so the collector marks and rewrites it
like any other precise root. All 12 ctx.current_closure_ptr consumers route
through the helper.

Emitted IR, before → after:

; before
%r19 = call i64 @js_closure_get_capture_bits(i64 %this_closure, i32 8)

; after
%r3 = alloca i64
%r4 = or i64 %this_closure, 9222527611924643840
store i64 %r4, ptr %r3
call void @js_shadow_slot_bind(i32 0, ptr %r3)
...
%r5 = load i64, ptr %r3
%r6 = and i64 %r5, 281474976710655
%r7 = call i64 @js_closure_get_capture_bits(i64 %r6, i32 8)

Scope kept deliberately tight:

  • Capture-less closures pay nothing. (a, b) => a - b emits no capture
    access, so no slot is reserved and no js_shadow_frame_push/pop pair is
    forced onto a body that needs no frame.
  • The typed-ABI clone is untouched and is not a hole.
    lower_typed_f64_body_* bails on anything that is not straight-line
    arithmetic ("typed-f64 clone cannot lower non-straight-line statement"), so
    a typed body contains no loop poll and no allocation — its %this_closure
    register cannot go stale. Same for the public trampoline, which only forwards
    the parameter.
  • The this / new.target capture reads keep using the raw parameter: they run
    in the entry-block prologue, ahead of any statement that can collect.
  • No runtime change — crates/perry-runtime is byte-identical to main.

Verification

Oracle is node v26.5.0 (.node-version), asserted before every run.
Release build, PERRY_NO_AUTO_OPTIMIZE not set (production path).

The issue's own w5_srv_scale.ts, one binary, checksum per arm:

requests node 26.5.0 before (shipped default) after (shipped default)
150 -341887099 -198241022 -341887099
300 -1598210268 -1454564191 -1598210268
600 1735266323 1878912400 1735266323

(The before-column error is the constant 143646077 at every length — this
tree's magnitude for the constant the issue reports as 251646625 at
e279b2d54. Same defect, same shape: one request's fold counted twice.)

The reduced 150-request pump (calls + checksum), all node-exact after:

arm before after
default calls:150 checksum:-342133776 same ✅
PERRY_GC_SCAVENGE_NURSERY_MB=1 calls:151 checksum:-340242326 node-exact ✅
PERRY_GC_SCAVENGE_NURSERY_MB=2 calls:151 node-exact ✅
PERRY_GC_SCAVENGE_NURSERY_MB=8 calls:151 node-exact ✅
PERRY_GC_MOVING_SAFEPOINT=0 node-exact ✅
PERRY_GC_SCAVENGE=1 node-exact ✅
PERRY_GC_FORCE_EVACUATE=1 node-exact ✅
PERRY_GEN_GC=0 (control) node-exact node-exact ✅

The fix is not inertness: the instrumented run still shows
MOVE closure … -> … on the fixed build. The closure is still relocated; the
body just follows it.

Regression coverage

Two tests, one per tier, both sabotage-verified in both directions.

  1. perry-codegen unit test (runs in the per-PR cargo-test gate)
    codegen::closure::tests::closure_body_roots_its_own_closure_pointer_and_reads_captures_through_it.
    Compiles a one-capture closure to IR (emit_ir_only) and asserts the body
    NaN-boxes %this_closure into a shadow-bound slot and that no
    js_closure_get_capture_bits(i64 %this_closure / set_capture_bits(i64 %this_closure remains. Non-vacuous: it also asserts the body reads a capture
    at all.

    sabotage A (drop the slot):             no shadow-framed closure body in IR  -> FAILED
    sabotage B (read the raw register):     capture reads must reload the closure
                                            pointer from its rooted slot         -> FAILED
    restored:                                                                       ok
    
  2. crates/perry/tests/gc_closure_self_pointer_root_7055.rs (runs per-PR via
    the e2e-scoped job, CI: PR cargo-test never executes crates/perry integration suites; main-push full run starves under merge trains (documented near-miss) #5960). Compiles the reduced pump and runs the same
    binary
    under 10 collector configurations — default, a
    PERRY_GC_SCAVENGE_NURSERY_MB sweep of 1/2/3/4/5/8/12/16, and PERRY_GEN_GC=0
    — each of which must reproduce node byte-for-byte. The sweep is what gives it
    teeth without flakiness: whether a relocating minor lands inside the state
    body depends on where the nursery happens to fill, so any single cap is a coin
    flip on a given host, while across the sweep the unfixed compiler fails at
    1, 2 and 8 MB.

    sabotage A: [PERRY_GC_SCAVENGE_NURSERY_MB=1]
        left: "calls:151\nchecksum:-340242326\n"  right: "calls:150\nchecksum:-342133776\n"  -> FAILED
    sabotage B: same assertion                                                              -> FAILED
    restored:                                                                                  ok
    

cargo test -p perry-codegen --lib: 283 passed, 0 failed.
cargo fmt --all -- --check: clean.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a garbage-collection issue where closures with captured values could lose their own state, causing capture reads to fail.
    • Prevented async generator loops from replaying iterations and producing incorrect accumulated results.
    • Improved correctness and reliability of captured array and variable updates, including across numeric coercions.
  • Tests
    • Expanded regression coverage to run the same program under multiple garbage-collection configurations and validate exact output.

…7055)

A closure body reaches its captures through
`js_closure_get_capture_bits(%this_closure, idx)`. `%this_closure` is an
LLVM parameter — a register value no root enumeration can see — and 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 closure relocated while its own body is on the stack
therefore left that register pointing into from-space, which the same
cycle resets and hands straight back to the mutator: the next capture
access read a foreign object's `capture_count`, judged the index out of
range and returned 0, so every later boxed-capture read yielded
`undefined` and every write was silently dropped.

In an `async fn` the casualty was the generator state machine's own
`__gen_state` box, so the `state = <next>` store at the end of a resumed
state body went nowhere and the following `await` resumed into the state
it had just finished — replaying one loop iteration and folding its
contribution into the accumulator twice.

Closure bodies with captures now spill `%this_closure` into a NaN-boxed
entry alloca bound to a shadow-stack slot, and every capture access
reloads the pointer from that slot, so the collector rewrites it like any
other precise root. Capture-less closures are unaffected: they emit no
capture access, so they keep their frame-free prologue.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Closure code generation now roots captured closures’ this_closure pointers in shadow-stack slots and reloads them for capture access. Shared helpers update pointer retrieval across closure-related paths, with LLVM IR and relocating-GC regression tests covering the fix.

Changes

Closure pointer rooting and access

Layer / File(s) Summary
Root and retrieve closure pointers
crates/perry-codegen/src/codegen/closure.rs, crates/perry-codegen/src/expr/shadow_slot.rs, crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/codegen/{entry,function,method}.rs
Captured closures spill tagged this_closure values into shadow-rooted slots, while FnCtx tracks the slot and shared helpers reload or report the current pointer.
Route captured operations through helpers
crates/perry-codegen/src/expr/*, crates/perry-codegen/src/codegen/arguments.rs, crates/perry-codegen/src/lower_array_method.rs
Capture reads, writes, updates, nested captures, array mutations, generator prototype linking, and argument lowering use the rooted-pointer helpers.
Validate rooted closure behavior
crates/perry-codegen/src/codegen/closure.rs, crates/perry/tests/gc_closure_self_pointer_root_7055.rs, changelog.d/7065-closure-self-pointer-gc-root.md
LLVM IR assertions and a multi-configuration runtime test verify closure rooting and the expected async-loop output during relocating GC.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant compile_closure
  participant shadow_stack
  participant capture_access
  participant relocating_gc
  compile_closure->>shadow_stack: bind NaN-boxed this_closure root
  capture_access->>shadow_stack: reload current closure pointer
  relocating_gc->>shadow_stack: update rooted pointer during evacuation
  capture_access->>shadow_stack: use relocated closure for capture access
Loading

Possibly related issues

  • PerryTS/perry issue 6981: Both address stale pointers across relocating GC, but this change targets closure this_closure roots while that issue concerns specialized-ABI argument pointers.

Possibly related PRs

  • PerryTS/perry#6905: Both modify closure code generation and shadow-slot GC-root behavior.

Suggested labels: bug

Suggested reviewers: thehypnoo, andrewtdiz

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed but does not follow the required template sections for Summary, Changes, Related issue, Test plan, or Checklist. Reformat the PR description to match the template and add concise Summary, Changes, Related issue, Test plan, Screenshots/output, and Checklist sections.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, specific, and accurately summarizes rooting a closure's own this_closure pointer.
Linked Issues check ✅ Passed The code changes match #7055 by rooting %this_closure, reloading it after GC-risky coercions, and adding regression coverage.
Out of Scope Changes check ✅ Passed The changes are focused on the closure-pointer GC fix and supporting tests/helpers, with no clear unrelated additions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7055-relocating-minor-wrong-answer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/codegen/closure.rs`:
- Around line 1114-1115: Update one_capture_closure_ir() to mark the closure
mutable, include capture index 0 in mutable_captures, and emit a captured
LocalSet(0, ...) in its body while retaining the existing capture writer absence
assertion. Keep the fixture focused on exercising mutable boxed-capture writes.

In `@crates/perry-codegen/src/expr/literals_vars.rs`:
- Line 706: Update the captured-local numeric update flow around
current_closure_ptr_value, js_to_numeric, and js_numeric_step so both pointers
are refreshed after coercion/stepping: reload closure_ptr immediately before the
final capture access, then re-fetch box_ptr from that fresh closure before
calling js_box_set_bits.

In `@crates/perry/tests/gc_closure_self_pointer_root_7055.rs`:
- Around line 132-137: Update the command setup in the arm execution loop to
remove inherited PERRY_GEN_GC settings before applying each arm’s environment
overrides. Ensure the default and nursery-capacity arms cannot be skipped or
altered by the test runner environment, while preserving each arm’s explicit
settings from arm.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ff4a6d7e-e417-4c69-a260-19e0f78de09b

📥 Commits

Reviewing files that changed from the base of the PR and between 788a757 and 84ce786.

📒 Files selected for processing (15)
  • changelog.d/7065-closure-self-pointer-gc-root.md
  • crates/perry-codegen/src/codegen/arguments.rs
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/array_push.rs
  • crates/perry-codegen/src/expr/closure.rs
  • crates/perry-codegen/src/expr/instance_misc1.rs
  • crates/perry-codegen/src/expr/literals_vars.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/shadow_slot.rs
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-codegen/src/lower_array_method.rs
  • crates/perry/tests/gc_closure_self_pointer_root_7055.rs

Comment thread crates/perry-codegen/src/codegen/closure.rs Outdated
Comment thread crates/perry-codegen/src/expr/literals_vars.rs
Comment thread crates/perry/tests/gc_closure_self_pointer_root_7055.rs
CodeRabbit review of 84ce786, three findings.

**Major (`literals_vars.rs`), valid in part.** In the captured-`Update`
arm the *unboxed* branch reused `closure_ptr` after `coerce_old` /
`step_new`, which emit `js_to_numeric` / `js_numeric_step`. The first of
those runs a user `valueOf` — arbitrary JS that can reach a
`js_gc_loop_safepoint` and relocate this closure — and
`js_closure_set_capture_bits` does NOT validate its pointer, so the write
would store into whatever the mutator had since put at that recycled
from-space address. The pointer is now re-read from the rooted slot
before the write, only on the coercing path.

The other half of the finding — "the boxed path also retains a stale
`box_ptr`" — is not a hazard. A box is `std::alloc::alloc`'d by
`js_box_alloc_bits`, is never freed and is never relocated
(`scan_box_roots_mut` rewrites the JSValue *inside* the box, not its
address), so an address read before a collection still names the same
live cell after it. Documented at the site instead of adding a redundant
re-fetch.

**Minor (fixture vacuity), valid.** The IR fixture only emitted
`LocalGet`, so its negative assertion on the capture *writer* could not
fail for the reason it claimed. It now emits a captured `LocalSet` and a
captured `x++`, and asserts positively that every capture access reloads
the rooted slot. A second fixture reaches the unboxed writer (the arm the
Major fix touches) by omitting the capture's declaration site, which is
what keeps `collect_boxed_vars` from boxing it.

**Minor (inherited env), valid.** `Command` inherits the test runner's
environment, so an exported `PERRY_GEN_GC=0` would have turned every arm
of the e2e suite into the never-relocates control and let it pass against
the unfixed compiler. The whole collector-knob family is now cleared
before each arm applies its own settings.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Review round 1 (CodeRabbit on 84ce7863a) — addressed in ace517aa5

🟠 Major, literals_vars.rs:706 — valid in part, fixed. The captured-Update arm's unboxed branch reused closure_ptr across js_to_numeric (which runs a user valueOf, i.e. arbitrary JS that can reach a loop poll and relocate the closure). js_closure_set_capture_bits does no validation — unlike the reader, which bounds-checks and returns 0 — so that write would have poked a word into recycled from-space. The pointer is now re-read from the rooted slot before the write, gated on needs_numeric_coerce so integer counters keep their two-instruction path. The finding's other half — a stale box_ptr — is not a hazard: boxes are std::alloc::alloc'd, never freed, and never relocated (scan_box_roots_mut rewrites the JSValue inside the box), so I documented that at the site instead of adding the re-fetch. The arm is not reachable from TypeScript today (collect_boxed_vars boxes every declared local a nested closure mutates; verified by dumping IR for three n++-on-a-capture shapes, all boxed), so there is no e2e case for it — a codegen test pins it instead.

🟡 Minor, fixture vacuity — valid, fixed. The IR fixture only emitted LocalGet, so its negative assertion on the capture writer could not fail for the reason it claimed. It now emits a captured LocalSet and a captured x++, and the coverage is stated positively: ≥ 2 capture accesses, a js_box_set_bits, a js_to_numeric, every access reloading the rooted slot (0 reloads pre-fix), and specifically the access after the coercion coming from a fresh load.

🟡 Minor, inherited environment — valid, fixed. Command inherits the runner's env, so an exported PERRY_GEN_GC=0 (or PERRY_GC_MOVING_SAFEPOINT=0, PERRY_CONSERVATIVE_STACK_SCAN=on, PERRY_WRITE_BARRIERS=0 — each independently disables relocation) would have turned every arm into the never-relocates control and let the suite pass against the unfixed compiler. All eleven collector knobs are cleared before each arm applies its own.

Coverage after this round

Three tests, each sabotage-verified against the change it guards:

test sabotage result
closure_body_roots_…_reads_captures_through_it drop the rooted slot no shadow-framed closure body in IR → FAILED
closure_body_roots_…_reads_captures_through_it read the raw %this_closure capture reads must reload … from its rooted slot → FAILED
unboxed_capture_write_reloads_the_closure_pointer_after_the_coercion drop the post-coercion reload must re-read the closure pointer from its rooted slot %r3 after js_to_numeric → FAILED
relocating_minor_does_not_replay_an_async_loop_iteration (e2e) either codegen sabotage calls:151 vs calls:150 → FAILED

cargo test -p perry-codegen --lib: 284 passed, 0 failed. e2e suite green over all 10 arms. cargo fmt --all -- --check clean.

@proggeramlug
proggeramlug merged commit c0d9862 into main Jul 30, 2026
6 of 7 checks passed
@proggeramlug
proggeramlug deleted the fix/7055-relocating-minor-wrong-answer branch July 30, 2026 11:40

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/codegen/closure.rs`:
- Around line 1325-1357: Update the reload assertions around the slot checks and
the corresponding checks near the later capture-access validation to match the
complete SSA slot token, not raw prefixes; require the slot reference to end at
the next non-digit character so `%5` cannot match `%52`. Preserve the existing
count and post-`js_to_numeric` validation behavior while applying the same
boundary-aware matching in both locations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 20266d33-75c3-4082-bf84-2cf15bfdbfb9

📥 Commits

Reviewing files that changed from the base of the PR and between 84ce786 and ace517a.

📒 Files selected for processing (3)
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/expr/literals_vars.rs
  • crates/perry/tests/gc_closure_self_pointer_root_7055.rs

Comment on lines +1325 to +1357
let slot = {
let bind = body
.find("@js_shadow_slot_bind(")
.map(|i| &body[i..])
.and_then(|t| t.split_once("ptr "))
.map(|(_, rest)| rest)
.unwrap_or_else(|| panic!("no shadow-slot bind in body:\n{body}"));
bind.split(')').next().expect("bind operand").to_string()
};
let reload = format!("load i64, ptr {slot}");
assert!(
body.matches(reload.as_str()).count() >= accesses,
"every one of the {accesses} capture accesses must reload the \
closure pointer from its rooted slot {slot}; body:\n{body}"
);

// #7055 (CodeRabbit): the sharp case — `js_to_numeric` runs a user
// `valueOf`, i.e. arbitrary JS that can reach a loop poll and relocate
// this closure. The capture access that FOLLOWS it must come from a
// fresh load, never from a pointer materialized before the call.
let coerce_at = body.find("@js_to_numeric").expect("coercion call");
let next_access = body[coerce_at..]
.find("@js_closure_get_capture_bits(")
.map(|i| coerce_at + i)
.unwrap_or_else(|| {
panic!("fixture must access the capture after the coercion; body:\n{body}")
});
assert!(
body[coerce_at..next_access].contains(reload.as_str()),
"a capture access after `js_to_numeric` (which can run a user \
`valueOf` and relocate the closure) must re-read the closure \
pointer from its rooted slot {slot}; body:\n{body}"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)closure\.rs$|perry-codegen/src/codegen/closure.rs' || true

echo "== around target lines =="
wc -l crates/perry-codegen/src/codegen/closure.rs
sed -n '1280,1435p' crates/perry-codegen/src/codegen/closure.rs | nl -ba -v1280

echo "== search match patterns =="
rg -n 'uses i64, ptr|`@js_shadow_slot_bind`|`@js_to_numeric`|`@js_closure_get_capture_bits`|matches|contains\(reload|count\(\) >= accesses' crates/perry-codegen/src/codegen/closure.rs

Repository: PerryTS/perry

Length of output: 433


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target block =="
sed -n '1280,1435p' crates/perry-codegen/src/codegen/closure.rs

echo "== exact match strings in target file =="
rg -n '`@js_shadow_slot_bind`\(|`@js_to_numeric`|`@js_closure_get_capture_bits`\(|load i64, ptr|\.matches\(|\.contains\(reload|assertions' crates/perry-codegen/src/codegen/closure.rs || true

Repository: PerryTS/perry

Length of output: 7976


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
def occurrences(haystack, needle):
    count = 0
    start = 0
    while True:
        idx = haystack.find(needle, start)
        if idx == -1:
            break
        count += 1
        start = idx + 1
    return count

def boundary_count(haystack, needle):
    hbytes = haystack.encode()
    nlen = len(needle)
    count = 0
    start = 0
    while True:
        idx = haystack.find(needle, start)
        if idx == -1:
            break
        if idx + nlen >= len(haystack) or not haystack[idx + nlen].isdigit():
            count += 1
        start = idx + 1
    return count

cases = [
    ("load i64, ptr %5 next", "load i64, ptr %5", 1, 1),
    ("load i64, ptr %52 next\nload i64, ptr %5", "load i64, ptr %5", 2, 1),
    ("load i64, ptr %5x\nload i64, ptr %5", "load i64, ptr %5", 2, 1),
    ("no instruction", "load i64, ptr %5", 0, 0),
]
for body, needle, _raw_expected, _boundary_expected in cases:
    print(repr(body), needle, occurrences(body, needle), boundary_count(body, needle))
PY

echo "== IR generator and fixture context =="
rg -n 'fn unboxed_capture_update_ir|fn .*closure.*ir|`@js_shadow_slot_bind`|`@load` i64|perry_closure|shadow_slot_bind|load i64, ptr' crates/perry-codegen/src/codegen/closure.rs crates/perry-codegen -g '!target'

Repository: PerryTS/perry

Length of output: 22249


Apply a trailing boundary to reload checks.

load i64, ptr {slot} currently matches longer SSA names that share the same numeric prefix, e.g. %52 when slot is %5; the same concern applies at lines 1394-1417. Match up to the next non-digit character instead of using raw substring search for these assertions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/codegen/closure.rs` around lines 1325 - 1357, Update
the reload assertions around the slot checks and the corresponding checks near
the later capture-access validation to match the complete SSA slot token, not
raw prefixes; require the slot reference to end at the next non-digit character
so `%5` cannot match `%52`. Preserve the existing count and post-`js_to_numeric`
validation behavior while applying the same boundary-aware matching in both
locations.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gc: the relocating minor produces a deterministic wrong answer in the SHIPPED default on an ordinary event-loop workload

1 participant