Skip to content

fix(codegen): root a call receiver and a computed-read base across their sibling operands - #7206

Merged
proggeramlug merged 5 commits into
PerryTS:mainfrom
jdalton:fix/7154-stale-register-roots
Aug 1, 2026
Merged

fix(codegen): root a call receiver and a computed-read base across their sibling operands#7206
proggeramlug merged 5 commits into
PerryTS:mainfrom
jdalton:fix/7154-stale-register-roots

Conversation

@jdalton

@jdalton jdalton commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Two ways a compiled program could crash, or quietly read the wrong value, when the garbage collector moved an object in the middle of a single expression. A method call like recv.m(f()) and a computed read like o[f()] both evaluate the object first and the second operand after — that is the order the spec requires — so the object sat in a plain CPU register while f() ran. If f() allocated enough to trigger a moving collection, the object was relocated and the register was left pointing at the old address. The call then threw TypeError: value is not a function, or the read segfaulted.

Both sites are fixed, both have a regression test that is red on the parent commit and green here, and both were found by a static check over the emitted LLVM IR rather than by a runtime probe — which matters, because at the moment of the collection there is nothing wrong for a runtime probe to see.

This only bites under PERRY_GC_MOVING_LOOP_POLLS=1, which has been off by default since #7161.

The invariant — rooting the place a value came from does not save the register you copied it into

#7192 fixed values whose root store was emitted late. These two are different: they are never rooted at all. A reference is read out of a root, kept in a bare SSA register across a collection point, and used below it.

The reference survives the collection — the closure capture cell, shadow slot or module global holding it is a root — so it moves. The collector rewrites that location and the register keeps naming from-space. This is exactly the "property (2) — a rewritten location — is worthless without property (3), reading that location again below the collection point" that expr/temp_root.rs's module header describes, and the same fix #7192 applied to the property/element STORE receiver.

Site one: the method-call receiver — a stale receiver makes method lookup resolve against abandoned memory, so the call throws TypeError: value is not a function

In lower_call/console_promise.rs, the js_native_call_method_by_id dispatch. In the sfw-registry corpus this is zod's classic/schemas.ts:301:

inst.regex = (...args) => inst.check(checks.regex(...args));

inst is read out of the arrow's capture cell, held across checks.regex(...args) — a real user call, so it polls — then used as .check's receiver. The IR before this change:

%r12 = call i64 @js_closure_get_capture_bits(i64 %r11, i32 0)   ; inst
%r13 = bitcast i64 %r12 to double                                ; a register, not a root
%r19 = call double @js_closure_call_apply_with_spread(...)       ; runs user code -> polls -> minor
%r24 = call double @js_typed_feedback_native_call_method_by_id(..., double %r13, ...)  ; STALE

The arguments are rooted too — each one before the next is lowered, per RootedOperands' incremental contract — so an earlier argument cannot go stale across a later one either.

Site two: the computed-read base — a stale base walks the keys array of freed memory, segfaulting inside get_field_by_name_object_tail

In expr/index_get.rs, the dynamic-string-key arm and the last-resort runtime-tag-check arm. These are the READ counterpart of #7192's index_set / property_set guard, which only covered the store side.

In sfw-registry this is zod's core/checks.ts:68, numericOriginMap[typeof def.value] — a module-global base with a key expression that reads a property and therefore can collect. The failure is either a SIGSEGV or, worse, a silently wrong value.

Why a temp root rather than re-lowering — re-lowering would observe an assignment the sibling operand made, which is a miscompile rather than a rooting fix

Both sites use temp_root::guard_store_operand / reread_store_operand / release_store_operand (#7198's generalized naming).

The guard emits nothing when the sibling expression cannot collect, so an inert argument list or key keeps its previous IR byte for byte. It is released after the dispatch, because the dispatcher allocates while reading these values.

The instrument: --stale-registers — a ranked lead list, not a gate, so it exits 0 unless you hand it a budget

The shipped check anchors on a shadow-slot bind, so it can only see values that are eventually rooted. Neither site above is, which is why #7192's checker reported the corpus at 1 violation while both of these were live.

--stale-registers checks the more general invariant the module header already states. It classifies every heap-value source — an allocation, or a read of a collector-rewritten location (a shadow-slot load, a closure capture cell, a temp-root slot, a module global, a mutable-capture box) — follows it forward through bit-level identity ops, and reports the first real use sitting below a collecting call. --fatal-sinks narrows to uses that dereference the value, where a relocation is fatal rather than merely wrong. It found both sites independently of any runtime probe, and it agreed with lldb on the exact instruction in each case.

It is not calibrated to zero: the raw count is dominated by values it cannot prove are pointers, and even the fatal-sink slice still carries the known-unfixed js_closure_call* class. A mode that returned non-zero on any hit would be a check that can never pass — the mirror image of CLAUDE.md's four "a gate that cannot fail" hazards, and just as reliably ignored. So the exit status is explicit:

Invocation Exit Meaning
--stale-registers 0 Diagnostic. Prints the counts and the ranked leads.
--stale-registers --max-stale N 1 above N Opt-in ratchet, for a slice that has been calibrated.
--max-stale or --fatal-sinks without --stale-registers 2 Usage error. A silently ignored budget, or a filter that never reached the report, is a disarmed knob.
--stale-registers over an empty corpus 2 --min-files is checked first; nothing was scanned.
The bind-anchored gate gc-root-dominance.yml runs 1 on any violation Unchanged by this PR.

--self-test asserts the top three rows against the planted and control fixtures, so the contract is checked on every PR rather than described in a comment. Reverting the default to return 1 if total else 0 fails three of those arms.

Validation — both gap tests flip red to green and stay green under a non-moving collector, so the failure tracks collector mode rather than luck

Ran.

parent this PR
test_gap_gc_method_receiver_rooting.ts, POLLS=1 TypeError: value is not a function 5/5 bad 0 10/10
test_gap_gc_index_get_receiver_rooting.ts, POLLS=1 TypeError: Cannot read properties of undefined 4/4 bad 0 10/10
both, POLLS=1 + PERRY_GEN_GC=0 bad 0 bad 0
both, default (no polls) bad 0 bad 0 5/5
static checker, fatal-sink slice, 141-module sfw-registry corpus 986 729
cargo test -p perry-codegen 6 loop_safepoint_purity failures identical to main
python3 scripts/gc_root_dominance_check.py --self-test OK, including the four new exit-status arms

The entire js_typed_feedback_native_call_method_by_id fatal-sink class (257) is gone.

Did not run. cargo test -p perry-runtime — this PR touches only perry-codegen, which perry-runtime does not depend on. The full parity sweep is likewise not re-run; the two new tests are gap tests and are covered by the gap gate.

Trade-off. The temp-root guard adds a root/re-read/release triple on the receiver and base paths whenever the sibling operand can collect. That is the cost of correctness under a moving collector, and it is skipped entirely when the sibling cannot collect, so inert argument lists and constant keys emit exactly the IR they did before.

What this does not closesfw-registry --help under moving polls is still red at 5/10, so #7161's stopgap stays

Measured at the merge base plus this PR, compiled and run with PERRY_GC_MOVING_LOOP_POLLS=1: red 5/10 (SIGSEGV). Its default arm is clean 10/10. Before these two fixes the same build was red 8/10, and before them the failure was the deterministic TypeError at zod schemas.ts:301 — so this is progress along the same chain, not a resolution.

The remaining 593 fatal-sink hits are all js_closure_callN: the generic dynamic-value-call lowering in console_promise.rs, which holds the callee and the this receiver and each argument in registers across the argument list. Three stale registers in the single hottest call path. That is the next site of this class and it is deliberately not fixed here — it is the central dispatch path and deserves its own change with its own repro.

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

Summary by CodeRabbit

  • Bug Fixes

    • Fixed rare errors when garbage collection relocates objects during computed property reads or method calls.
    • Improved reliability when evaluating allocating expressions before accessing object receivers or arguments.
  • Tests

    • Added regression coverage for moving-garbage-collection scenarios involving computed properties and dynamic method calls.
  • Documentation

    • Documented the garbage-collection safety fixes and updated the release version to 0.5.1279.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d738b12-dcba-4eb5-9896-3333e5a7f774

📥 Commits

Reviewing files that changed from the base of the PR and between 1f9d710 and 08a665d.

📒 Files selected for processing (1)
  • test-parity/gc_repsel_corpus.txt

📝 Walkthrough

Walkthrough

The change roots receivers and arguments across potentially collecting expressions and dispatch. It adds moving-GC regression tests and extends gc_root_dominance_check.py with stale-register analysis, filtering, budgets, CLI validation, and self-tests.

Changes

Stale GC receiver handling

Layer / File(s) Summary
Receiver rooting and regression coverage
crates/perry-codegen/src/expr/index_get.rs, crates/perry-codegen/src/lower_call/console_promise.rs, test-files/*gc*receiver*, test-parity/gc_repsel_corpus.txt, changelog.d/7206-stale-receiver-registers.md
Computed reads and native method calls root receivers and arguments, reread relocated values, and release roots after dispatch. Moving-GC tests and parity entries cover both paths.
Stale-register source and use analysis
scripts/gc_root_dominance_check.py
The checker models heap-value sources and aliases, detects uses after collection windows, reports stale values, and filters fatal sinks.
Diagnostic CLI and self-tests
scripts/gc_root_dominance_check.py, changelog.d/7206-stale-receiver-registers.md
The checker adds stale-analysis options and --max-stale budget handling. Self-tests validate reporting, budgets, invalid combinations, and clean fixtures.

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

Possibly related issues

Possibly related PRs

Suggested reviewers: proggeramlug

Sequence Diagram(s)

sequenceDiagram
  participant Codegen
  participant GC
  participant RuntimeDispatch
  participant RegressionTest
  Codegen->>Codegen: Root receiver and arguments
  Codegen->>GC: Evaluate allocating key or argument
  GC-->>Codegen: Relocate rooted values
  Codegen->>Codegen: Reread rooted values
  Codegen->>RuntimeDispatch: Dispatch with refreshed values
  RuntimeDispatch-->>RegressionTest: Return computed result
  RegressionTest->>RegressionTest: Check repeated moving-GC results
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% 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 primary fix: rooting call receivers and computed-read bases across sibling operand evaluation.
Description check ✅ Passed The description thoroughly covers the problem, implementation, tests, validation results, related issues, and remaining limitations, despite omitting the template checklist.
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: 1

🧹 Nitpick comments (2)
test-files/test_gap_gc_method_receiver_rooting.ts (1)

31-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Both gap tests can lose their collection window to constant folding. In each test the allocation-heavy helper returns a value that does not depend on the objects it allocates, so a later optimizer can delete the loop and the allocations. The test would then pass without reaching an evacuating minor, and the regression would go undetected.

  • test-files/test_gap_gc_method_receiver_rooting.ts#L31-L37: derive churn's return value from the allocated bits elements and from x, instead of the constant bits.length - 600.
  • test-files/test_gap_gc_index_get_receiver_rooting.ts#L28-L34: make the pushed objects depend on v and derive the "hit" decision from a read of an allocated element, instead of the constant bits.length === 4000.
🤖 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 `@test-files/test_gap_gc_method_receiver_rooting.ts` around lines 31 - 37, The
allocation loops in both gap-test helpers must produce observable values. In
test-files/test_gap_gc_method_receiver_rooting.ts lines 31-37, update churn so
its return depends on x and values read from allocated bits elements, not only
bits.length - 600; in test-files/test_gap_gc_index_get_receiver_rooting.ts lines
28-34, make pushed objects depend on v and derive the "hit" result from reading
an allocated element instead of the constant bits.length === 4000.
scripts/gc_root_dominance_check.py (1)

875-901: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Build and index SSA uses once before expanding each heap source.

def_of is allocated at lines 875-879 but not used by this stale-use closure. Lines 891-901 scan every instruction for every register in chain on every fixpoint iteration, which makes the stale-use check sources × instructions × iterations. Move a users index into the per-function setup and expand chain with a worklist over matched register users; reuse operand_regs() so the existing SSA operand matching is preserved.

🤖 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 875 - 901, Build a
per-function SSA users index alongside def_of, mapping each register returned by
operand_regs(ins.text) to the instructions that use it. In the heap-source
closure around heap_source_kind, replace repeated full-function scans and grew
iterations with a worklist seeded by src.result, processing indexed users once
and adding results only for is_transparent instructions. Preserve the existing
chain semantics and operand matching.
🤖 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 `@scripts/gc_root_dominance_check.py`:
- Around line 978-983: Update run_stale() so --stale-registers remains
diagnostic by returning 0 after reporting the stale-register counts, regardless
of whether total is nonzero. Preserve the existing ranked output and main()
flow, without introducing a gating threshold.

---

Nitpick comments:
In `@scripts/gc_root_dominance_check.py`:
- Around line 875-901: Build a per-function SSA users index alongside def_of,
mapping each register returned by operand_regs(ins.text) to the instructions
that use it. In the heap-source closure around heap_source_kind, replace
repeated full-function scans and grew iterations with a worklist seeded by
src.result, processing indexed users once and adding results only for
is_transparent instructions. Preserve the existing chain semantics and operand
matching.

In `@test-files/test_gap_gc_method_receiver_rooting.ts`:
- Around line 31-37: The allocation loops in both gap-test helpers must produce
observable values. In test-files/test_gap_gc_method_receiver_rooting.ts lines
31-37, update churn so its return depends on x and values read from allocated
bits elements, not only bits.length - 600; in
test-files/test_gap_gc_index_get_receiver_rooting.ts lines 28-34, make pushed
objects depend on v and derive the "hit" result from reading an allocated
element instead of the constant bits.length === 4000.
🪄 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: df51bd99-1f3f-4d3f-9c82-92954edc80a4

📥 Commits

Reviewing files that changed from the base of the PR and between 3a983ca and 5c9e328.

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

Comment thread scripts/gc_root_dominance_check.py Outdated

@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 `@scripts/gc_root_dominance_check.py`:
- Around line 1141-1168: Validate ns.fatal_sinks alongside ns.max_stale in the
argument validation after ns.parse_args(), rejecting it with ap.error unless
ns.stale_registers is enabled. Ensure --fatal-sinks alone cannot reach the
bind-anchored check in main().
🪄 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: 3efdc0eb-0463-4073-a64d-46aa0d311ead

📥 Commits

Reviewing files that changed from the base of the PR and between 5c9e328 and 8560ca0.

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

Comment thread scripts/gc_root_dominance_check.py
…eir sibling operands

Two more sites of PerryTS#7192's root-store-dominance class, found by extending its
checker with the general stale-register invariant and each reproduced in ~30
lines of TypeScript.

`recv.m(f())` and `o[f()]` evaluate the reference first and the second operand
after — spec order, and codegen follows it. That left the reference in a bare
SSA register while `f()` was lowered, and `f()` allocates. Under
PERRY_GC_MOVING_LOOP_POLLS=1 a loop back-edge poll inside it runs an evacuating
minor. The reference SURVIVES that minor — the closure capture cell, shadow slot
or module global holding it is a root — so it MOVES: the collector rewrites that
location and the register keeps naming from-space. Same "property (2), a
rewritten location, is worthless without property (3), reading that location
again below the collection point" that expr/temp_root.rs's module header
describes, and the same fix PerryTS#7192 applied to the property/element STORE
receiver.

  * lower_call/console_promise.rs — the js_native_call_method_by_id dispatch.
    The stale receiver makes the method lookup resolve against abandoned memory,
    so the call throws "TypeError: value is not a function". In sfw-registry
    this is zod classic/schemas.ts:301,
    `inst.regex = (...args) => inst.check(checks.regex(...args))`: `inst` is read
    out of the arrow's capture cell, held across `checks.regex(...args)` (a real
    user call, so it polls), then used as `.check`'s receiver. The arguments are
    rooted too — each before the NEXT one is lowered, per RootedOperands'
    incremental contract — so an earlier argument cannot go stale across a later
    one either.
  * expr/index_get.rs — the dynamic-string-key arm and the last-resort
    runtime-tag-check arm: the READ counterpart of PerryTS#7192's index_set /
    property_set guard, which only covered the store side. The stale base makes
    the field read walk the keys array of from-space memory — a SIGSEGV inside
    get_field_by_name_object_tail, or a silently wrong value. In sfw-registry
    this is zod core/checks.ts:68, `numericOriginMap[typeof def.value]`: a
    module-global base with a key expression that reads a property and therefore
    can collect.

Both use temp_root::guard_store_operand / reread_store_operand /
release_store_operand (PerryTS#7198's generalized naming). A temp root, not a re-lower:
re-lowering the reference would observe an assignment made by the second operand
itself, which is a miscompile rather than a rooting fix. The guard emits nothing
when the sibling expression cannot collect, so an inert argument list or key
keeps its previous IR exactly, and it is released AFTER the dispatch because the
dispatcher allocates while reading these values.

Verified by two new gap tests, each red on the parent commit and green after,
and each clean under a non-moving collector so the failure is proven to track
collector mode rather than luck:

  test_gap_gc_method_receiver_rooting.ts   POLLS=1  parent: TypeError 5/5
                                                    this:   bad 0 10/10
  test_gap_gc_index_get_receiver_rooting.ts POLLS=1 parent: TypeError 4/4
                                                    this:   bad 0 10/10
  both, POLLS=1 + PERRY_GEN_GC=0                    bad 0
  both, default (no polls)                          bad 0 5/5

scripts/gc_root_dominance_check.py grows --stale-registers. The shipped check
anchors on a shadow-slot bind, so it can only see values that are eventually
rooted; neither site above is. The new mode classifies every heap-value source
(an allocation, or a read of a collector-rewritten location: a shadow-slot load,
a closure capture cell, a temp-root slot, a module global, a mutable-capture
box), follows it forward through bit-level identity ops, and reports the first
real use below a collecting call. --fatal-sinks narrows to uses that DEREFERENCE
the value (a call receiver or callee), where a relocation is fatal rather than
merely wrong. Over the 141-module sfw-registry corpus the fatal-sink slice went
986 -> 729 with this change and the entire
js_typed_feedback_native_call_method_by_id class (257) is gone. The remaining
593 are js_closure_callN — the generic dynamic-value-call lowering, which holds
the callee AND the `this` receiver AND each argument in registers across the
argument list. That is the next site of this class and it is NOT fixed here.

That mode is a diagnostic, so it EXITS 0 and reports counts. It is not
calibrated to zero — the raw count is dominated by values the checker cannot
prove are pointers, and even the --fatal-sinks slice still carries the
js_closure_callN class above — so returning 1 on any hit would be a check that
can never pass, the mirror image of CLAUDE.md's four "a gate that cannot fail"
hazards and just as reliably ignored. Gating is opt-in via --max-stale N, which
exits 1 above the budget, so a calibrated slice can become a ratchet later
without the raw mode pretending to be one. --max-stale or --fatal-sinks without
--stale-registers is a usage error (exit 2) rather than a silently ignored
budget or an ignored filter. --self-test
asserts all of it: the default must report 2 uses on the planted fixture and
still exit 0, --max-stale 0 must exit 1, --max-stale 2 must exit 0, and the
control fixture must report zero. The bind-anchored gate gc-root-dominance.yml
actually runs is untouched and still exits non-zero on any violation.

sfw-registry --help under PERRY_GC_MOVING_LOOP_POLLS=1 (compiled AND run with
the flag) is still red — 8/10 SIGSEGV before these fixes surfaced past the
TypeError, 5/10 after — so PerryTS#7161's stopgap stays. Its default arm is clean
10/10.

cargo test -p perry-codegen: the 6 loop_safepoint_purity failures from PerryTS#7161's
default flip, identical to main. cargo test -p perry-runtime: unchanged — this
commit touches only perry-codegen, which perry-runtime does not depend on.

Refs PerryTS#7154, PerryTS#7192, PerryTS#7198, PerryTS#7184, PerryTS#7161, PerryTS#7114, PerryTS#6951.
@jdalton
jdalton force-pushed the fix/7154-stale-register-roots branch from 8560ca0 to bf8bede Compare August 1, 2026 19:12
Comment thread scripts/gc_root_dominance_check.py

@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

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

865-931: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

--fatal-sinks can miss a genuinely fatal use when a non-fatal use of the same source is found first.

check_func_stale stops at the first non-transparent real use of a source below a collection point, then run_stale filters that single result post-hoc with v.fatal_sink (Line 964-965). If the first dominated use of a source is benign (for example, a store to a non-root slot) but a later use on a different path or block is a genuine receiver/callee dereference, the search never reaches it, since it already committed to the first candidate and broke out of both loops. --fatal-sinks exists specifically to surface the dereference case, so under this flag the search should keep looking past a non-fatal candidate instead of stopping there.

🔧 Proposed fix: thread `fatal_only` through the search so it keeps looking until a fatal use is found
-def check_func_stale(module, f, poll_reaching=frozenset(), moving_only=False):
+def check_func_stale(module, f, poll_reaching=frozenset(), moving_only=False,
+                      fatal_only=False):
     """Report registers holding a heap value that are USED below a collection
     point without being re-read from a root."""
     ...
                     v = StaleUse(module, f.name, src, kind, use, hits,
                                  src.result, poll_reaching)
                     if moving_only and not v.moving:
                         continue
+                    if fatal_only and not v.fatal_sink:
+                        continue
                     out.append(v)
                     break
             for v in check_func_stale(mod, f, poll_reaching, moving_only):
-                if fatal_only and not v.fatal_sink:
-                    continue
+            for v in check_func_stale(mod, f, poll_reaching, moving_only,
+                                       fatal_only):
                 total += 1

This keeps default (non---fatal-sinks) behavior unchanged and only makes the fatal-sinks search more thorough.

🤖 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 865 - 931, Update
check_func_stale to accept and propagate a fatal_only option, and when enabled
continue scanning past non-fatal StaleUse candidates until finding a fatal sink;
preserve the current first-match behavior when fatal_only is disabled. Update
run_stale to pass its --fatal-sinks state into check_func_stale and avoid
relying on post-hoc filtering that prevents later uses from being examined.
🤖 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 `@scripts/gc_root_dominance_check.py`:
- Around line 1141-1171: Prevent --any-def from being silently ignored with
--stale-registers by rejecting that combination during argument validation,
alongside the existing --max-stale and --fatal-sinks checks. For --min-binds,
either detect explicit use and reject it in stale-register mode, or restructure
its default so explicit overrides can be distinguished; at minimum update the
--min-binds help text to state that it applies only outside --stale-registers
mode.

---

Nitpick comments:
In `@scripts/gc_root_dominance_check.py`:
- Around line 865-931: Update check_func_stale to accept and propagate a
fatal_only option, and when enabled continue scanning past non-fatal StaleUse
candidates until finding a fatal sink; preserve the current first-match behavior
when fatal_only is disabled. Update run_stale to pass its --fatal-sinks state
into check_func_stale and avoid relying on post-hoc filtering that prevents
later uses from being examined.
🪄 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: 40537a0f-0c99-435b-b951-46550c2f5886

📥 Commits

Reviewing files that changed from the base of the PR and between 8560ca0 and bf8bede.

📒 Files selected for processing (6)
  • changelog.d/7206-stale-receiver-registers.md
  • crates/perry-codegen/src/expr/index_get.rs
  • crates/perry-codegen/src/lower_call/console_promise.rs
  • scripts/gc_root_dominance_check.py
  • test-files/test_gap_gc_index_get_receiver_rooting.ts
  • test-files/test_gap_gc_method_receiver_rooting.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/perry-codegen/src/lower_call/console_promise.rs
  • crates/perry-codegen/src/expr/index_get.rs
  • test-files/test_gap_gc_index_get_receiver_rooting.ts
  • changelog.d/7206-stale-receiver-registers.md
  • test-files/test_gap_gc_method_receiver_rooting.ts

Comment on lines +1141 to +1171
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). Diagnostic: this "
"mode exits 0 and reports counts unless --max-stale "
"gives it a budget")
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")
ap.add_argument("--max-stale", type=int, default=None, metavar="N",
help="with --stale-registers, exit 1 when more than N uses "
"are reported. Without it the mode is a ranked lead "
"list, not a pass/fail number, so it exits 0.")
ap.add_argument("--min-files", type=int, default=1, metavar="N",
help="fail unless at least N .ll files were scanned (default 1)")
ap.add_argument("--min-binds", type=int, default=1, metavar="N",
help="fail unless at least N root stores were seen (default 1). "
"A clean verdict over zero root stores proves nothing.")
ns = ap.parse_args()

# A knob that is silently ignored is a disarmed knob: `--max-stale 0`
# without `--stale-registers` would run the bind-anchored check and look
# like it enforced a budget, and `--fatal-sinks` alone would look like it
# narrowed a report it never reached. Refuse instead (argparse exits 2).
if ns.max_stale is not None and not ns.stale_registers:
ap.error("--max-stale requires --stale-registers")
if ns.fatal_sinks and not ns.stale_registers:
ap.error("--fatal-sinks requires --stale-registers")

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

--any-def and --min-binds are silently ignored when combined with --stale-registers.

Lines 1167-1170 already guard --max-stale and --fatal-sinks against being used without --stale-registers, with the stated rationale that "a knob that is silently ignored is a disarmed knob." The reverse gap exists for --any-def and --min-binds: when --stale-registers is set, the stale path returns at Line 1205-1207 before anchor (built from --any-def) or n_binds/ns.min_binds are ever consulted, so both flags have no effect and no diagnostic is printed.

--any-def is a simple boolean and can be guarded the same way as the existing checks:

🔧 Proposed fix for `--any-def`
     if ns.max_stale is not None and not ns.stale_registers:
         ap.error("--max-stale requires --stale-registers")
     if ns.fatal_sinks and not ns.stale_registers:
         ap.error("--fatal-sinks requires --stale-registers")
+    if ns.any_def and ns.stale_registers:
+        ap.error("--any-def has no effect with --stale-registers")

--min-binds defaults to 1, so an explicit --min-binds 1 cannot be distinguished from the default with a plain truthiness check. If detecting an explicit override is not worth the default=None restructuring, at minimum update its help text to state it only applies outside --stale-registers mode, so a user passing --min-binds 50 --stale-registers is not misled into believing the corpus size was validated.

🤖 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 1141 - 1171, Prevent
--any-def from being silently ignored with --stale-registers by rejecting that
combination during argument validation, alongside the existing --max-stale and
--fatal-sinks checks. For --min-binds, either detect explicit use and reject it
in stale-register mode, or restructure its default so explicit overrides can be
distinguished; at minimum update the --min-binds help text to state that it
applies only outside --stale-registers mode.

Ralph Küpper added 2 commits August 1, 2026 21:24
# Conflicts:
#	scripts/gc_root_dominance_check.py
…the two PerryTS#7206 sites

Merge-time fix, not a change of intent. PerryTS#7207 (`1679e22b4`, merged after this
PR branched) widened `reread_store_operand` to take the operand expression and
return `anyhow::Result<String>` so its new `Reload` arm can re-lower a
string-literal base instead of reusing a register taken before the collection
point. The two call sites added here still passed the old three-argument form.

git merged both files cleanly because the change is in a DIFFERENT file
(`temp_root.rs`) from the call sites (`index_get.rs`) -- a textually clean
merge that does not compile. Adopting the new signature also gives these two
sites PerryTS#7207's strictly better treatment of a literal base (`"abc"[k]`).

@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)
scripts/gc_root_dominance_check.py (2)

1369-1389: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Assert the --unrooted-allocas gate exit status.

Lines 1369-1389 only test _scan_unrooted(). They do not execute the CLI gate in Lines 1499-1542. A change that makes the final return always succeed will keep this self-test green. Add probes that assert exit 1 for the planted fixture and exit 0 for the rooted control.

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 1369 - 1389, Extend the
self-test near the _scan_unrooted checks to invoke the --unrooted-allocas CLI
gate for both the planted unrooted fixture and the rooted control, and assert
exit status 1 and 0 respectively. Use direct subprocess exit-code checks rather
than piped wrapper commands, while preserving the existing scanner assertions.

Source: Coding guidelines


1438-1442: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject combined analysis modes.

If callers pass --stale-registers --unrooted-allocas, Lines 1487-1489 return from stale analysis before the unrooted-alloca gate runs. The default stale mode exits 0, so this invocation can pass while unrooted-alloca violations exist. Reject this flag combination, or explicitly run both analyses.

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 1438 - 1442, Reject the
incompatible --stale-registers and --unrooted-allocas combination during
argument validation before either analysis runs. Ensure the command exits
nonzero with a clear diagnostic, preventing the stale-registers early return
from skipping the unrooted-alloca check; preserve each mode’s existing behavior
when used alone.

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 `@scripts/gc_root_dominance_check.py`:
- Around line 1369-1389: Extend the self-test near the _scan_unrooted checks to
invoke the --unrooted-allocas CLI gate for both the planted unrooted fixture and
the rooted control, and assert exit status 1 and 0 respectively. Use direct
subprocess exit-code checks rather than piped wrapper commands, while preserving
the existing scanner assertions.
- Around line 1438-1442: Reject the incompatible --stale-registers and
--unrooted-allocas combination during argument validation before either analysis
runs. Ensure the command exits nonzero with a clear diagnostic, preventing the
stale-registers early return from skipping the unrooted-alloca check; preserve
each mode’s existing behavior when used alone.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4f18d3f6-2fd9-4d1a-bdac-d57e69fc983a

📥 Commits

Reviewing files that changed from the base of the PR and between bf8bede and 1f9d710.

📒 Files selected for processing (2)
  • crates/perry-codegen/src/expr/index_get.rs
  • scripts/gc_root_dominance_check.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-codegen/src/expr/index_get.rs

Ralph Küpper added 2 commits August 1, 2026 23:10
…rpus

Both are moving-only: clean on the shipped default on both sides of the fix,
so they certify nothing on the `default` arm and belong with the
`requires=move` rows. Measured red-then-green numbers are in the comment
block.
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.
@proggeramlug
proggeramlug merged commit a349642 into PerryTS:main Aug 1, 2026
1 of 8 checks passed
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…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.
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…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 pushed a commit that referenced this pull request Aug 1, 2026
…e_callN (#7214)

* fix(codegen): root the callee, `this` and every argument of js_closure_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. #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 #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 #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 #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 #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 #7154, #7206, #7192, #7198, #7184, #7161, #7114, #6951, #519.

* chore(7214): merge-time fixes — fragment name, rustfmt, corpus registration

- changelog fragment was PR-misnumbered `7207-`; #7207 is a different, already
  merged change. Renamed to `7214-`. Content already referenced #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 #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`.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 1, 2026
…ly, and document the invariant (#7212)

* fix(codegen): root the callee, `this` and every argument of js_closure_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. #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 #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 #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 #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 #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 #7154, #7206, #7192, #7198, #7184, #7161, #7114, #6951, #519.

* chore(7214): merge-time fixes — fragment name, rustfmt, corpus registration

- changelog fragment was PR-misnumbered `7207-`; #7207 is a different, already
  merged change. Renamed to `7214-`. Content already referenced #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 #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`.

* ci(gc): make the root-dominance gate able to fail, baseline it honestly, and document the invariant

Squashed. See PR description for the seeded-violation proof and the allowlist rationale.

* docs(gc): correct the post-#7207 staleness in the rooting-invariant writeup

Merge-time corrections to statements #7207 invalidated while this PR was in
review:

- CLAUDE.md called `lower_call/new.rs`'s inline-ctor `this_slot` "still
  open". #7207 closed it. Point at `--unrooted-allocas` as the detector for
  that shape and name #7210 as where its remaining hits are tracked.
- The rooting-invariant doc documented `--stale-registers` but not
  `--unrooted-allocas`, so the one mode the bind-anchored check is
  structurally blind to had no entry. Add it, and state plainly that the gate
  does NOT run it and that its hits are deliberately outside the allowlist —
  the allowlist covers the bind-anchored shape only.
- "all five known shapes" -> "every known shape", so the pointer cannot go
  stale the next time one is found.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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