Skip to content

fix(codegen): root the callee, this and every argument of js_closure_callN - #7214

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
jdalton:fix/7154-closure-calln-roots
Aug 1, 2026
Merged

fix(codegen): root the callee, this and every argument of js_closure_callN#7214
proggeramlug merged 2 commits into
PerryTS:mainfrom
jdalton:fix/7154-closure-calln-roots

Conversation

@jdalton

@jdalton jdalton commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Stacked on #7206 — branch is based on fix/7154-stale-register-roots, so review that first. Base is main; the diff will shrink to just this commit once #7206 merges.

The site #7206 named and deliberately left open: js_closure_callN, the generic dynamic-value-call lowering, which held the callee, the this receiver and every already-lowered argument in bare SSA registers across the argument list. Three stale registers in the single hottest emitted call path, and the last known instance of #7192's root-store-dominance class.

The three registers

An SSA register is not a GC root. Under PERRY_GC_MOVING_LOOP_POLLS=1 a back-edge poll inside an argument runs an evacuating minor: each held value survives — the capture cell, shadow slot or module global it was read from is a root — and therefore moves. The collector rewrites that location; the register keeps naming from-space.

register window it was live across symptom
callee every argument js_closure_unbox_callee_checked masks a pre-move address, js_closure_callN reads a closure header out of abandoned memory → TypeError: value is not a function
this receiver the callee read off it + every argument the rebind clones captures out of abandoned memory; the body's this.x reads garbage
argument i the arguments after i + the rebind unbox the callee receives a pre-move address as an argument

Why this is not a copy of #7206's fix

The three windows are different, so they are computed separately rather than protected as one block. The argument window is the subtle one:

js_closure_unbox_callee_checked_rebind calls clone_closure_rebind_this, which allocates a replacement closure (closure/dynamic_props.rs:1040) when the callee captures this. It sits below the last argument and above js_closure_callN — so the arguments must be re-read below it, while the callee and receiver are consumed by it.

RootedOperands::reread re-reads the whole group at one point, which is wrong for this shape. Hence reread_one: re-read a single operand at a caller-chosen point.

Hoisting the unbox above the argument list would remove the window instead, and was rejected: its throw is observable and the spec evaluates arguments before it. On the >16-arity path the argument stores into the stack buffer moved below the unbox for the same reason — a stack buffer is not a root, so filling it above an allocating rebind just freezes pre-move addresses one indirection further out.

Receiverless calls take js_closure_unbox_callee_checked, a tag check and a mask that allocates nothing, so f(x, y) on inert operands emits exactly the IR it emitted before.

Temp roots, not re-lowering: re-lowering the callee or receiver would observe an assignment made by an argument, which is a miscompile rather than a rooting fix.

The false green that nearly shipped

PERRY_GC_MOVING_LOOP_POLLS is compile-time since #7161 and runtime-armed (gc_moving_loop_polls_enabled(), gc/policy.rs:1759). The first cut of these three gap tests passed 10/10 on the unfixed parent because the harness set the flag only at compile time: the safepoint calls were emitted and then returned immediately. Both, or the test proves nothing.

Verification

parent this PR
test_gap_gc_closure_call_callee_rooting.ts, POLLS=1 TypeError: value is not a function 10/10 bad 0 10/10
test_gap_gc_closure_call_this_rooting.ts, POLLS=1 TypeError: value is not a function 10/10 bad 0 10/10
test_gap_gc_closure_call_argument_rooting.ts, POLLS=1 TypeError: value is not a function 10/10 bad 0 10/10
all three, POLLS=1 + PERRY_GEN_GC=0 bad 0 bad 0 5/5
all three, default (no polls) bad 0 bad 0 5/5
cargo test -p perry-codegen 27 failures identical 27, measured on the parent rather than assumed
bind-anchored checker (CI gate invocation) 1, 0 moving-reachable same — the residual #7192 left

cargo test detail: 6 loop_safepoint_purity (#7161's flip), 16 native_proof_regressions, 3 native_proof_buffer_views, 1 shadow_slot_hygiene, 1 typed_shape_descriptors — all pre-existing on the parent. One perry-codegen lib unit test that is red on the parent passes here.

Cost — measured, not assumed

This is the central dispatch path, so it was measured over the 141-module sfw-registry corpus. operand_protection emits nothing for an operand whose window cannot collect, which is why the delta is this small:

before after delta
linked binary 39,216,688 B 39,233,200 B +0.042 %
emitted IR 1,999,570 lines 2,001,607 lines +0.10 %
js_gc_temp_root_push sites 8,394 8,885 +491

Checker: a cited NONCOLLECTING entry, and what it revealed

js_closure_unbox_callee_checked is now in NONCOLLECTING, citing closure/unbox.rs:25 — a tag check on the NaN-boxed callee and a low-48 mask. No allocation, no user code, no poll.

It sits between every dynamic call's last argument and its js_closure_callN, so its absence reported the entire argument list of every 1-arg dynamic call as stale. 372 of the 729 fatal-sink hits were that single false positive, all marked MOVING: no — which is why the raw count barely moved after the fix and why the corrected numbers are the ones quoted. This is the checker's one-sided discipline working as designed: a missing entry costs false positives, never a missed bug.

js_closure_unbox_callee_checked_rebind is deliberately not added — it allocates, and the fix above depends on it counting as a collection point.

Fatal-sink slice against the corrected list: 231 → 205.

⚠️ What this does NOT close

sfw-registry --help under a genuine POLLS=1 build is still red, so #7161's stopgap stays. Compiled and run with the flag on this build: 3/10 pass, 7/10 SIGSEGV. The default arm is clean 10/10, so nothing was traded away.

The three registers were real and are now provably rooted, but they are not the last thing standing between the registry and a clean evacuating minor. Two concrete leads, both found while fixing the above and neither speculative:

  1. prev_this in this same lowering is the same bug, unfixed. js_implicit_this_set returns the previous implicit this, read out of the IMPLICIT_THIS cell — which object/this_binding.rs:176 documents as a scanned mutable root the collector rewrites. That value is then held in a bare register across the entire user call and written back afterwards, so a collection anywhere inside the callee makes the restore publish a from-space pointer back into a root. It is invisible to the current checker on both ends: js_implicit_this_set is not in ROOT_READ_CALLS, and it is not a RECEIVER_SINKS fatal sink. Fixing it costs a temp root on every dynamic call, so it was measured and left rather than folded in here.
  2. The 205 remaining fatal sinks are no longer dominated by any single class — 37 js_closure_call1, 22 js_closure_call2, 18 js_closure_call_apply_with_spread, 17 js_array_spread_append, 15 js_object_set_field_by_name, 15 js_array_concat, and a long tail. The spread path (expr/call_spread.rs) is the obvious next site: same dispatch family, untouched by fix(codegen): root a call receiver and a computed-read base across their sibling operands #7206 and by this PR.

Refs #7154, #7206, #7192, #7198, #7184, #7161, #7114, #6951, #519.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed dynamic closure calls that could use stale values after garbage collection.
    • Improved reliability when call receivers, callees, or arguments trigger memory allocation.
    • Preserved correct this binding and variadic argument handling during dynamic calls.
  • Tests

    • Added regression and stress tests covering callee, receiver, and argument rooting across moving garbage collection.
  • Documentation

    • Documented the fix, validation results, and remaining limitations.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Dynamic closure-call lowering now roots and rereads receivers, callees, and arguments across allocation windows. It adds per-operand rereading, updates root-dominance classification, and adds moving-GC regression coverage and corpus entries.

Changes

Moving-GC rooting

Layer / File(s) Summary
Operand rereading
crates/perry-codegen/src/expr/temp_root.rs
RootedOperands::reread_one independently rereads rooted, reloadable, or unchanged operands.
Closure-call rooting
crates/perry-codegen/src/lower_call/console_promise.rs
Closure calls root receivers, callees, and arguments across evaluation and rebinding. The lowering rereads moved values before dispatch and delays variadic buffer stores.
Root-dominance classification
scripts/gc_root_dominance_check.py, changelog.d/7214-closure-calln-stale-registers.md
The checker classifies checked closure unboxing as non-collecting and closure rebinding as collecting.
Regression coverage
test-files/test_gap_gc_closure_call_*.ts, test-parity/gc_repsel_corpus.txt, changelog.d/7214-closure-calln-stale-registers.md
Tests and corpus entries cover stale dynamic-call arguments, callees, and receivers during moving GC. The changelog records measurements and remaining cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClosureCallLowering
  participant RootedOperands
  participant MovingGC
  participant RuntimeDispatch
  ClosureCallLowering->>RootedOperands: root receiver, callee, and arguments
  ClosureCallLowering->>MovingGC: evaluate allocating arguments and rebind receiver
  MovingGC-->>RootedOperands: relocate rooted values
  ClosureCallLowering->>RootedOperands: reread receiver, callee, and arguments
  ClosureCallLowering->>RuntimeDispatch: dispatch closure call
  RuntimeDispatch-->>ClosureCallLowering: return result
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7212 — Directly overlaps the operand rereading, closure-call lowering, and root-dominance changes.
  • PerryTS/perry#7206 — Updates the same closure-call lowering path for allocation-safe operand rooting.
  • PerryTS/perry#6983 — Provides related RootedOperands rereading infrastructure for closure-call operands.

Suggested labels: run-extended-tests

Suggested reviewers: proggeramlug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main fix: rooting the callee, this, and arguments in js_closure_callN.
Description check ✅ Passed The description thoroughly explains the problem, implementation, tests, performance impact, related issues, and remaining work.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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: 4

🤖 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 `@changelog.d/7207-closure-calln-stale-registers.md`:
- Around line 116-117: Escape the leading hash in the wrapped changelog sentence
beginning with “#7161” so markdownlint MD018 no longer interprets it as a
malformed heading, while preserving the rendered text and surrounding
formatting.

In `@crates/perry-codegen/src/expr/index_get.rs`:
- Around line 2030-2037: Update the dynamic-string-key path in the index-get
lowering to root the receiver and key material across key materialization and
any allocation points. After unbox_str_handle and receiver-root rereads,
re-derive the key and receiver handles from their current rooted values before
calling js_typed_feedback_object_get_field_by_name_f64; do not pass stale bare
obj_handle or key_handle values after the root is released.

In `@crates/perry-codegen/src/lower_call/console_promise.rs`:
- Around line 1526-1529: Root the value returned by js_implicit_this_set in a
TempRoot slot before the allocating rebind unbox and user-code dispatch, then
reread that rooted value immediately before the restore at the dispatch cleanup.
Release the prev_this root after roots so nested temporary roots are released
inner-to-outer, and use the existing valid temporary-root APIs rather than an
untracked alloca.

In `@scripts/gc_root_dominance_check.py`:
- Around line 1091-1099: Move the n_binds computation and --min-binds validation
in the main argument-handling flow before dispatching to run_stale or the
bind-anchored path. Ensure both --stale-registers and the default check reject
corpora with fewer than ns.min_binds root stores, while preserving each path’s
existing behavior after the shared guard.
🪄 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: 87d369df-fa8e-4fda-b3dc-a609be0d12cc

📥 Commits

Reviewing files that changed from the base of the PR and between 3a983ca and 91e06f3.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7200-stale-receiver-registers.md
  • changelog.d/7207-closure-calln-stale-registers.md
  • crates/perry-codegen/src/expr/index_get.rs
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/console_promise.rs
  • scripts/gc_root_dominance_check.py
  • test-files/test_gap_gc_closure_call_argument_rooting.ts
  • test-files/test_gap_gc_closure_call_callee_rooting.ts
  • test-files/test_gap_gc_closure_call_this_rooting.ts
  • test-files/test_gap_gc_index_get_receiver_rooting.ts
  • test-files/test_gap_gc_method_receiver_rooting.ts

Comment on lines +116 to +117
**`sfw-registry --help` under a genuine `POLLS=1` build is still red, so
#7161's stopgap stays.** Measured on this build, compiled *and* run with the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the leading # to avoid an MD018 markdownlint hit.

Line 117 starts with #7161's stopgap stays.** after the wrap. markdownlint's MD018 rule treats a line-leading # without a following space as a malformed ATX heading. Escape it so the sentence still reads correctly without triggering the lint rule.

📝 Proposed fix to escape the leading hash
 **`sfw-registry --help` under a genuine `POLLS=1` build is still red, so
-#7161's stopgap stays.** Measured on this build, compiled *and* run with the
+\`#7161`'s stopgap stays.** Measured on this build, compiled *and* run with the
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
**`sfw-registry --help` under a genuine `POLLS=1` build is still red, so
#7161's stopgap stays.** Measured on this build, compiled *and* run with the
**`sfw-registry --help` under a genuine `POLLS=1` build is still red, so
\`#7161`'s stopgap stays.** Measured on this build, compiled *and* run with the
🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 117-117: No space after hash on atx style heading

(MD018, no-missing-space-atx)

🤖 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 `@changelog.d/7207-closure-calln-stale-registers.md` around lines 116 - 117,
Escape the leading hash in the wrapped changelog sentence beginning with “#7161”
so markdownlint MD018 no longer interprets it as a malformed heading, while
preserving the rendered text and surrounding formatting.

Source: Linters/SAST tools

Comment thread crates/perry-codegen/src/expr/index_get.rs Outdated
Comment thread crates/perry-codegen/src/lower_call/console_promise.rs
Comment on lines +1091 to +1099
ap.add_argument("--stale-registers", action="store_true",
help="check the general stale-register invariant instead of "
"the bind-anchored one: report every register holding a "
"GC value that is USED below a collection point without "
"being re-read from a root (#7154)")
ap.add_argument("--fatal-sinks", action="store_true",
help="with --stale-registers, keep only uses that "
"DEREFERENCE the stale value (a call receiver/callee), "
"where a relocation is fatal rather than merely wrong")

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 | 🟠 Major | ⚡ Quick win

--stale-registers bypasses the --min-binds safety check.

run_stale is dispatched at Line 1140 before n_binds is computed (Line 1143) and before the --min-binds guard (Line 1181). The bind-anchored path validates that at least ns.min_binds root stores exist in the corpus, because a corpus with zero root stores means the IR was not compiled the way the checker assumes (for example PERRY_INLINE_SHADOW_SLOT=0), and a clean verdict over such a corpus proves nothing.

The stale-register path relies on the same BIND_RE-derived slot_of_alloca map for slotload classification, so it has the identical failure mode: a misconfigured build produces zero heap-value sources, run_stale reports total == 0, and the script exits 0 — a false-clean CI result for the exact invariant this PR depends on to catch GC rooting regressions.

Compute n_binds and apply the --min-binds guard before dispatching to either code path.

🐛 Proposed fix to run the min-binds guard before either code path
     parsed = []
     for p in sorted(paths):
         parsed.append((os.path.basename(p), parse_file(p)))
     poll_reaching, _known = compute_poll_reaching(
         [f for _m, fs in parsed for f in fs])
-    if ns.stale_registers:
-        return run_stale(parsed, poll_reaching, verbose, moving_only,
-                         ns.fatal_sinks)
     n_binds = sum(
         1
         for _m, fs in parsed
         for f in fs
         for b in f.blocks
         for ins in f.insns[b]
         if BIND_RE.search(ins.text)
     )
+    if n_binds < ns.min_binds:
+        print(f"error: {n_binds} root store(s) in the corpus, need at least "
+              f"{ns.min_binds}. A clean verdict here means the IR was not the "
+              "IR you think it is (compile with PERRY_INLINE_SHADOW_SLOT=0).",
+              file=sys.stderr)
+        return 2
+    if ns.stale_registers:
+        return run_stale(parsed, poll_reaching, verbose, moving_only,
+                         ns.fatal_sinks)

Also applies to: 1135-1150, 1181-1186

🤖 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 `@scripts/gc_root_dominance_check.py` around lines 1091 - 1099, Move the
n_binds computation and --min-binds validation in the main argument-handling
flow before dispatching to run_stale or the bind-anchored path. Ensure both
--stale-registers and the default check reject corpora with fewer than
ns.min_binds root stores, while preserving each path’s existing behavior after
the shared guard.

proggeramlug added a commit that referenced this pull request Aug 1, 2026
…ts, and snapshot the Object.assign string source (#7216)

* fix(gc): bind the catch parameter and the closure this/new.target slots; snapshot the Object.assign string source (#7209, #7208, #7214)

* docs(changelog): fragment for #7216

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/gc_root_dominance_check.py (1)

1493-1500: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

--stale-registers still bypasses the --min-binds safety check.

Line 1498 dispatches run_stale before n_binds is computed at Line 1501 and before the --min-binds guard at Line 1584. This is the same gap raised on a previous commit: the bind-anchored path validates that at least ns.min_binds root stores exist in the corpus, because a corpus with zero root stores means the IR was not compiled the way the checker assumes. The stale-register path has the identical exposure, but nothing enforces the floor before run_stale returns.

A related, second gap in the same dispatch block: if a user passes both --stale-registers and --unrooted-allocas, --stale-registers silently wins at Line 1498 and the --unrooted-allocas branch at Line 1510 never executes, with no error. Compare this to the explicit ap.error guards already added for --max-stale/--fatal-sinks at Lines 1460-1463, which exist specifically because "a knob that is silently ignored is a disarmed knob."

Compute n_binds and apply the --min-binds guard before dispatching to run_stale, and reject the --stale-registers/--unrooted-allocas combination explicitly.

🐛 Proposed fix to gate `--stale-registers` on `--min-binds`
     parsed = []
     for p in sorted(paths):
         parsed.append((os.path.basename(p), parse_file(p)))
     poll_reaching, _known = compute_poll_reaching(
         [f for _m, fs in parsed for f in fs])
-    if ns.stale_registers:
-        return run_stale(parsed, poll_reaching, verbose, moving_only,
-                         ns.fatal_sinks, ns.max_stale)
     n_binds = sum(
         1
         for _m, fs in parsed
         for f in fs
         for b in f.blocks
         for ins in f.insns[b]
         if BIND_RE.search(ins.text)
     )
+    if ns.stale_registers:
+        if n_binds < ns.min_binds:
+            print(f"error: {n_binds} root store(s) in the corpus, need at "
+                  f"least {ns.min_binds}. A clean verdict here means the IR "
+                  "was not the IR you think it is (compile with "
+                  "PERRY_INLINE_SHADOW_SLOT=0).", file=sys.stderr)
+            return 2
+        return run_stale(parsed, poll_reaching, verbose, moving_only,
+                         ns.fatal_sinks, ns.max_stale)

Also applies to: 1584-1589

🤖 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 `@scripts/gc_root_dominance_check.py` around lines 1493 - 1500, Update the
dispatch logic around run_stale to compute n_binds and enforce the existing
ns.min_binds safety check before returning from the --stale-registers path. Add
an explicit argument error rejecting simultaneous --stale-registers and
--unrooted-allocas, so neither mode silently overrides the other; preserve the
existing validation behavior for the remaining options.
♻️ Duplicate comments (1)
changelog.d/7214-closure-calln-stale-registers.md (1)

116-117: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Escape the leading # to avoid the MD018 markdownlint hit.

Line 117 still starts with #7161's stopgap stays.** after the wrap. markdownlint's MD018 rule treats a line-leading # without a following space as a malformed ATX heading, and the static analysis hint confirms this fires again at Line 117 in this commit. This is the same issue raised on a previous commit of this fragment (then named 7207-closure-calln-stale-registers.md) and it remains unresolved.

📝 Proposed fix to escape the leading hash
 **`sfw-registry --help` under a genuine `POLLS=1` build is still red, so
-#7161's stopgap stays.** Measured on this build, compiled *and* run with the
+`#7161`'s stopgap stays.** Measured on this build, compiled *and* run with the
🤖 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 `@changelog.d/7214-closure-calln-stale-registers.md` around lines 116 - 117,
Escape the leading hash in the wrapped changelog sentence so the line containing
“#7161's stopgap stays.” no longer begins with a raw # character; preserve the
existing wording and Markdown formatting.

Source: Linters/SAST tools

🧹 Nitpick comments (1)
scripts/gc_root_dominance_check.py (1)

1239-1247: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated GC-capable-alloca counting loop.

The loop that counts GC-capable allocas at Lines 1516-1523 (inside the CLI --unrooted-allocas branch) duplicates the same loop already in _scan_unrooted at Lines 1239-1247. Extract a small helper, e.g. _count_gc_capable_allocas(parsed), and call it from both sites.

♻️ Proposed extraction
+def _count_gc_capable_allocas(parsed):
+    n = 0
+    for _m, fs in parsed:
+        for f in fs:
+            for b in f.blocks:
+                for ins in f.insns[b]:
+                    am = ALLOCA_RE.match(ins.text)
+                    if am and any(am.group(2).strip().startswith(t)
+                                  for t in GC_CAPABLE_ALLOCA_TYPES):
+                        n += 1
+    return n
+
+
 def _scan_unrooted(paths):
     """(violations, n_gc_capable_allocas) over `paths`."""
     parsed = [(os.path.basename(p), parse_file(p)) for p in sorted(paths)]
     poll_reaching, _known = compute_poll_reaching(
         [f for _m, fs in parsed for f in fs])
-    n = 0
-    for _m, fs in parsed:
-        for f in fs:
-            for b in f.blocks:
-                for ins in f.insns[b]:
-                    am = ALLOCA_RE.match(ins.text)
-                    if am and any(am.group(2).strip().startswith(t)
-                                  for t in GC_CAPABLE_ALLOCA_TYPES):
-                        n += 1
+    n = _count_gc_capable_allocas(parsed)

Also applies to: 1510-1523

🤖 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 `@scripts/gc_root_dominance_check.py` around lines 1239 - 1247, Extract the
duplicated GC-capable-alloca counting logic into a helper such as
_count_gc_capable_allocas(parsed), preserving the existing traversal and
ALLOCA_RE/GC_CAPABLE_ALLOCA_TYPES checks. Replace the inline loop in
_scan_unrooted and the corresponding CLI --unrooted-allocas branch with calls to
this helper.
🤖 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.

Outside diff comments:
In `@scripts/gc_root_dominance_check.py`:
- Around line 1493-1500: Update the dispatch logic around run_stale to compute
n_binds and enforce the existing ns.min_binds safety check before returning from
the --stale-registers path. Add an explicit argument error rejecting
simultaneous --stale-registers and --unrooted-allocas, so neither mode silently
overrides the other; preserve the existing validation behavior for the remaining
options.

---

Duplicate comments:
In `@changelog.d/7214-closure-calln-stale-registers.md`:
- Around line 116-117: Escape the leading hash in the wrapped changelog sentence
so the line containing “#7161's stopgap stays.” no longer begins with a raw #
character; preserve the existing wording and Markdown formatting.

---

Nitpick comments:
In `@scripts/gc_root_dominance_check.py`:
- Around line 1239-1247: Extract the duplicated GC-capable-alloca counting logic
into a helper such as _count_gc_capable_allocas(parsed), preserving the existing
traversal and ALLOCA_RE/GC_CAPABLE_ALLOCA_TYPES checks. Replace the inline loop
in _scan_unrooted and the corresponding CLI --unrooted-allocas branch with calls
to this helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a2ac677-634d-4bdf-8fac-8eb19d081af2

📥 Commits

Reviewing files that changed from the base of the PR and between 91e06f3 and 25131ae.

📒 Files selected for processing (6)
  • changelog.d/7206-stale-receiver-registers.md
  • changelog.d/7214-closure-calln-stale-registers.md
  • crates/perry-codegen/src/expr/index_get.rs
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/console_promise.rs
  • scripts/gc_root_dominance_check.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/perry-codegen/src/expr/index_get.rs
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/console_promise.rs

proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…corpus

Moving-only, like PerryTS#7206's pair: clean on the shipped default on both sides of
the fix. The comment block records the three distinct rooting windows and the
red-then-green measured against a PerryTS#7206-only build, which is what makes these
'the sites PerryTS#7206 left open' rather than a restatement of it.
jdalton and others added 2 commits August 1, 2026 23:43
…e_callN

The generic dynamic-value-call lowering held THREE classes of GC value in bare
SSA registers across work that can collect. `js_closure_callN` is the central
dispatch path -- `f(g())`, `o.m(g())`, `curry(1)(2)`, every call whose callee is
a value rather than a statically resolved function -- and this is the site

An SSA register is not a GC root. Under PERRY_GC_MOVING_LOOP_POLLS=1 a back-edge
poll inside an argument runs an evacuating minor: each held value SURVIVES (the
capture cell, shadow slot or module global it was read from is a root) and
therefore MOVES. The collector rewrites that location; the register keeps naming
from-space.

  * the CALLEE, held across the whole argument list. The checked unbox masks a
    pre-move address and js_closure_callN reads a closure header out of
    abandoned memory: "TypeError: value is not a function".
  * the `this` RECEIVER, held across the read of the callee off it AND the
    argument list. PerryTS#7206 fixed this operand on the sibling
    js_native_call_method_by_id dispatch; this is the generic one.
  * each already-lowered ARGUMENT, held across the arguments after it AND
    across the rebind unbox.

The three live windows differ, so they are computed separately:

  receiver   | the callee read + every argument
  callee     | every argument
  argument i | the arguments after i + the rebind unbox

That last window is why this is not a copy of PerryTS#7206's fix.
js_closure_unbox_callee_checked_rebind calls clone_closure_rebind_this, which
ALLOCATES a replacement closure (closure/dynamic_props.rs:1040) when the callee
captures `this`. It sits below the last argument and above js_closure_callN, so
the arguments are re-read below it -- hence RootedOperands::reread_one, which
re-reads one operand at a caller-chosen point instead of the whole group at one.
Hoisting the unbox above the argument list would remove the window instead, but
its throw is observable and the spec evaluates arguments before it. On the
>16-arity path the argument stores into the stack buffer moved below the unbox
for the same reason: a stack buffer is not a root, so filling it above an
allocating rebind freezes pre-move addresses one indirection further out.

Receiverless calls take js_closure_unbox_callee_checked, a tag check and a mask
that allocates nothing, so `f(x, y)` on inert operands emits exactly its old IR.
Temp roots, not re-lowering: re-lowering the callee or receiver would observe an
assignment made by an argument, a miscompile rather than a rooting fix.

Three gap tests, one per held value, each red on the parent under a GENUINE
POLLS=1 build and green after. The flag is compile-time since PerryTS#7161 AND
runtime-armed (gc_moving_loop_polls_enabled(), gc/policy.rs:1759) -- setting
only one is a false green, and the first cut of these tests passed 10/10 for
exactly that reason.

  callee / this / argument, POLLS=1     parent: TypeError 10/10 each
                                        this:   bad 0    10/10 each
  all three, POLLS=1 + PERRY_GEN_GC=0   bad 0 5/5
  all three, default (no polls)         bad 0 5/5

Cost over the 141-module sfw-registry corpus, measured rather than assumed
because this is the hottest emitted call path. operand_protection emits nothing
for an operand whose window cannot collect, which is why the delta is small:
linked binary 39,216,688 -> 39,233,200 B (+0.042%), emitted IR 1,999,570 ->
2,001,607 lines (+0.10%), js_gc_temp_root_push sites 8,394 -> 8,885.

scripts/gc_root_dominance_check.py gains js_closure_unbox_callee_checked in
NONCOLLECTING, citing closure/unbox.rs:25 -- a tag check and a low-48 mask, no
allocation, no user code, no poll. It sits between every dynamic call's last
argument and its js_closure_callN, so its absence reported the whole argument
list of every 1-arg dynamic call as stale: 372 of the 729 fatal-sink hits were
that single false positive, all marked MOVING: no. The _rebind variant is
deliberately NOT added -- it allocates, and the fix above depends on it counting
as a collection point. Fatal-sink slice against the corrected list: 231 -> 205.

cargo test -p perry-codegen: failing set IDENTICAL to the parent (6
loop_safepoint_purity, 16 native_proof_regressions, 3 native_proof_buffer_views,
1 shadow_slot_hygiene, 1 typed_shape_descriptors), measured on the parent commit
rather than assumed; one lib unit test red on the parent passes here. The
bind-anchored gate reports the same single non-moving residual PerryTS#7192 left.

WHAT THIS DOES NOT CLOSE: sfw-registry --help under a genuine POLLS=1 build is
still red -- 3/10 pass, 7/10 SIGSEGV -- so PerryTS#7161's stopgap STAYS. Its default arm
is clean 10/10, so nothing was traded away. Two concrete leads are written up in
the changelog fragment: `prev_this` in this same lowering is the same bug
unfixed (js_implicit_this_set returns a value read from the scanned, rewritten
IMPLICIT_THIS cell and holds it across the entire user call), and the remaining
205 fatal sinks are no longer dominated by one class, with the spread dispatch
(expr/call_spread.rs) the obvious next site.

Refs PerryTS#7154, PerryTS#7206, PerryTS#7192, PerryTS#7198, PerryTS#7184, PerryTS#7161, PerryTS#7114, PerryTS#6951, PerryTS#519.
…ration

- changelog fragment was PR-misnumbered `7207-`; PerryTS#7207 is a different, already
  merged change. Renamed to `7214-`. Content already referenced PerryTS#7206
  correctly and is unchanged.
- `cargo fmt --all -- --check` is a required check on `lint`; one hand-wrapped
  `roots.push` call needed re-wrapping. No behaviour change.
- Registered the three witnesses in the GC x repsel corpus, next to PerryTS#7206's
  pair. All three are moving-only: clean on the shipped default on both sides
  of the fix, so they belong with the `requires=move` rows and prove nothing
  on `default`.
@proggeramlug
proggeramlug force-pushed the fix/7154-closure-calln-roots branch from 8d2a264 to e0fce62 Compare August 1, 2026 21:59
@proggeramlug
proggeramlug merged commit fa4a7ab into PerryTS:main Aug 1, 2026
0 of 8 checks passed

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/perry-codegen/src/lower_call/console_promise.rs (1)

1416-1430: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

prev_this is still an unrooted GC sink.

js_implicit_this_set returns prev_this at Line 1418 or Line 1427. That value stays in a bare SSA register across the allocating rebind unbox at Lines 1461-1475 and across the whole dispatch at Lines 1486-1519. Neither location is a GC root. The restore at Lines 1525-1529 can write a pre-move address back into the implicit-this cell after a moving collection.

Push a temp root right after js_implicit_this_set produces prev_this. Re-read it just before the restore. Release that root after roots so nested releases run inner to outer.

As per coding guidelines: "Ensure GC-managed values are rooted before every possible collection point and that the root store dominates all such sites; use shadow slots or temporary roots rather than untracked allocas."

Also applies to: 1525-1529

🤖 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/lower_call/console_promise.rs` around lines 1416 -
1430, Root the value returned by js_implicit_this_set immediately after each
prev_this assignment, including both method_recv and receiverless-call branches.
Re-read the rooted value immediately before the implicit-this restore around the
dispatch cleanup, then release this temporary root after releasing roots so
nested roots unwind inner-to-outer; do not use the stale SSA value across the
allocating rebind or dispatch.

Source: Coding guidelines

scripts/gc_root_dominance_check.py (1)

1498-1500: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

--stale-registers still bypasses the --min-binds safety check.

run_stale is dispatched here before n_binds is computed (Line 1501) and before the --min-binds guard (Line 1584). heap_source_kind's "slotload" classification depends on slot_of_alloca, which is derived from the same BIND_RE matches used to compute n_binds. A corpus with zero root stores (a misconfigured build, e.g. PERRY_INLINE_SHADOW_SLOT=1) makes check_func_stale find fewer or no violations, so --stale-registers --max-stale N can report a false-clean result over IR that was never checked.

Compute n_binds and apply the --min-binds guard before dispatching to either code path.

As per coding guidelines: "A CI gate must assert that the behavior it measures actually executed, not merely that the test completed without throwing; verify gate exit codes directly rather than relying on piped wrapper commands."

🤖 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 `@scripts/gc_root_dominance_check.py` around lines 1498 - 1500, Move the
n_binds computation and --min-binds guard ahead of the ns.stale_registers
dispatch, so both run_stale and the normal path validate the bind-count safety
threshold before proceeding. Preserve the existing run_stale arguments and
behavior after this guard, and ensure the CI gate checks the underlying
command’s exit status directly rather than relying on a piped wrapper result.

Source: Coding guidelines

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

Outside diff comments:
In `@crates/perry-codegen/src/lower_call/console_promise.rs`:
- Around line 1416-1430: Root the value returned by js_implicit_this_set
immediately after each prev_this assignment, including both method_recv and
receiverless-call branches. Re-read the rooted value immediately before the
implicit-this restore around the dispatch cleanup, then release this temporary
root after releasing roots so nested roots unwind inner-to-outer; do not use the
stale SSA value across the allocating rebind or dispatch.

In `@scripts/gc_root_dominance_check.py`:
- Around line 1498-1500: Move the n_binds computation and --min-binds guard
ahead of the ns.stale_registers dispatch, so both run_stale and the normal path
validate the bind-count safety threshold before proceeding. Preserve the
existing run_stale arguments and behavior after this guard, and ensure the CI
gate checks the underlying command’s exit status directly rather than relying on
a piped wrapper result.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e398d9b9-757a-487e-ab71-129b2469eed0

📥 Commits

Reviewing files that changed from the base of the PR and between 8d2a264 and e0fce62.

📒 Files selected for processing (8)
  • changelog.d/7214-closure-calln-stale-registers.md
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/console_promise.rs
  • scripts/gc_root_dominance_check.py
  • test-files/test_gap_gc_closure_call_argument_rooting.ts
  • test-files/test_gap_gc_closure_call_callee_rooting.ts
  • test-files/test_gap_gc_closure_call_this_rooting.ts
  • test-parity/gc_repsel_corpus.txt
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/perry-codegen/src/expr/temp_root.rs
  • test-files/test_gap_gc_closure_call_this_rooting.ts
  • test-files/test_gap_gc_closure_call_callee_rooting.ts

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.

2 participants