fix(codegen): root a call receiver and a computed-read base across their sibling operands - #7206
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe change roots receivers and arguments across potentially collecting expressions and dispatch. It adds moving-GC regression tests and extends ChangesStale GC receiver handling
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 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
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
test-files/test_gap_gc_method_receiver_rooting.ts (1)
31-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winBoth 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: derivechurn's return value from the allocatedbitselements and fromx, instead of the constantbits.length - 600.test-files/test_gap_gc_index_get_receiver_rooting.ts#L28-L34: make the pushed objects depend onvand derive the"hit"decision from a read of an allocated element, instead of the constantbits.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 winBuild and index SSA uses once before expanding each heap source.
def_ofis allocated at lines 875-879 but not used by this stale-use closure. Lines 891-901 scan every instruction for every register inchainon every fixpoint iteration, which makes the stale-use checksources × instructions × iterations. Move ausersindex into the per-function setup and expandchainwith a worklist over matched register users; reuseoperand_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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (8)
CLAUDE.mdCargo.tomlchangelog.d/7200-stale-receiver-registers.mdcrates/perry-codegen/src/expr/index_get.rscrates/perry-codegen/src/lower_call/console_promise.rsscripts/gc_root_dominance_check.pytest-files/test_gap_gc_index_get_receiver_rooting.tstest-files/test_gap_gc_method_receiver_rooting.ts
5c9e328 to
8560ca0
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (6)
changelog.d/7206-stale-receiver-registers.mdcrates/perry-codegen/src/expr/index_get.rscrates/perry-codegen/src/lower_call/console_promise.rsscripts/gc_root_dominance_check.pytest-files/test_gap_gc_index_get_receiver_rooting.tstest-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
…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.
8560ca0 to
bf8bede
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
scripts/gc_root_dominance_check.py (1)
865-931: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
--fatal-sinkscan miss a genuinely fatal use when a non-fatal use of the same source is found first.
check_func_stalestops at the first non-transparent real use of a source below a collection point, thenrun_stalefilters that single result post-hoc withv.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-sinksexists 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) breakfor 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 += 1This 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
📒 Files selected for processing (6)
changelog.d/7206-stale-receiver-registers.mdcrates/perry-codegen/src/expr/index_get.rscrates/perry-codegen/src/lower_call/console_promise.rsscripts/gc_root_dominance_check.pytest-files/test_gap_gc_index_get_receiver_rooting.tstest-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
| 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") | ||
|
|
There was a problem hiding this comment.
🎯 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.
# 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]`).
There was a problem hiding this comment.
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 winAssert the
--unrooted-allocasgate 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 exit1for the planted fixture and exit0for 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 winReject 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 exits0, 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
📒 Files selected for processing (2)
crates/perry-codegen/src/expr/index_get.rsscripts/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
…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.
…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.
…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`.
…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>
…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>
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 likeo[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 whilef()ran. Iff()allocated enough to trigger a moving collection, the object was relocated and the register was left pointing at the old address. The call then threwTypeError: 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 functionIn
lower_call/console_promise.rs, thejs_native_call_method_by_iddispatch. In thesfw-registrycorpus this is zod'sclassic/schemas.ts:301:instis read out of the arrow's capture cell, held acrosschecks.regex(...args)— a real user call, so it polls — then used as.check's receiver. The IR before this change: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_tailIn
expr/index_get.rs, the dynamic-string-key arm and the last-resort runtime-tag-check arm. These are the READ counterpart of #7192'sindex_set/property_setguard, which only covered the store side.In
sfw-registrythis is zod'score/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 budgetThe 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-registerschecks 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-sinksnarrows 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:--stale-registers0--stale-registers --max-stale N1aboveN--max-staleor--fatal-sinkswithout--stale-registers2--stale-registersover an empty corpus2--min-filesis checked first; nothing was scanned.gc-root-dominance.ymlruns1on any violation--self-testasserts 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 toreturn 1 if total else 0fails 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.
test_gap_gc_method_receiver_rooting.ts,POLLS=1TypeError: value is not a function5/5bad 010/10test_gap_gc_index_get_receiver_rooting.ts,POLLS=1TypeError: Cannot read properties of undefined4/4bad 010/10POLLS=1+PERRY_GEN_GC=0bad 0bad 0bad 0bad 05/5sfw-registrycorpuscargo test -p perry-codegenloop_safepoint_purityfailurespython3 scripts/gc_root_dominance_check.py --self-testThe entire
js_typed_feedback_native_call_method_by_idfatal-sink class (257) is gone.Did not run.
cargo test -p perry-runtime— this PR touches onlyperry-codegen, whichperry-runtimedoes 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 close —
sfw-registry --helpunder moving polls is still red at 5/10, so #7161's stopgap staysMeasured 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 deterministicTypeErrorat zodschemas.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 inconsole_promise.rs, which holds the callee and thethisreceiver 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
Tests
Documentation