fix(gc): close the three residual root-store holes — the PutValueSet key, js_object_assign_one, and the inline-ctor this slot - #7207
Conversation
|
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)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe PR fixes moving-GC root-store holes in dynamic property writes, ChangesMoving-GC rooting
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ObjectAssignLowering
participant TemporaryRoot
participant js_object_assign_one
participant MovingGC
ObjectAssignLowering->>TemporaryRoot: root target
ObjectAssignLowering->>js_object_assign_one: pass rooted target and source
js_object_assign_one->>MovingGC: run copy, getter, or proxy operations
MovingGC-->>TemporaryRoot: update relocated values
js_object_assign_one-->>ObjectAssignLowering: return current target
ObjectAssignLowering->>TemporaryRoot: publish returned target
Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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: 3
🧹 Nitpick comments (4)
crates/perry-codegen/src/expr/index_set.rs (1)
1505-1508: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRelease
key_guardexplicitly to match the sibling store paths.This arm pushes
key_guardafterrecv_guard, so its slot index is higher. Line 1544 releases onlyrecv_guard, andtemp_root_truncatedrops every slot at and above the given index, so the key slot is dropped as a side effect. The result is correct today.The same lowering shape in
crates/perry-codegen/src/expr/proxy_reflect.rsat lines 1360-1361 releases both guards in inner-to-outer order. Adding the explicit key release here keeps the two paths symmetric and keeps the code correct if the push order is ever changed.♻️ Proposed explicit release
], ); + super::temp_root::release_store_operand(ctx, key_guard); super::temp_root::release_store_operand(ctx, recv_guard); return Ok(val_double);Based on learnings: release the key guard before the receiver guard when both are present, because temporary-root releases truncate slots above their index.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-codegen/src/expr/index_set.rs` around lines 1505 - 1508, In the affected index-set lowering arm, explicitly release the key temporary-root guard before releasing the receiver guard. Update the cleanup sequence around reread_store_operand and the existing recv_guard release so key_guard is released first, preserving inner-to-outer order and matching the sibling proxy_reflect path.Source: Learnings
crates/perry-codegen/src/expr/scalar_slot_root.rs (1)
102-145: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider renaming the scalar-specific identifiers now that the helper is general.
root_entry_allocadocuments three non-scalar callers: the inline-constructorthis_slot, closure capturedthis/new.targetslots, and acatch (e)parameter slot. The cache map is stillctx.scalar_slot_shadow_slotsand the barrier helper is stillemit_scalar_slot_store_barrier. Both names now describe only one of several callers, so a reader oflower_call/new.rssees a scalar-replacement map used for a constructor slot.Rename to something scope-accurate, for example
entry_alloca_shadow_slotsandemit_entry_alloca_store_barrier. The map lives onFnCtx, so this touches the struct definition and the scalar-replacement call sites.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-codegen/src/expr/scalar_slot_root.rs` around lines 102 - 145, Rename the generalized entry-alloca shadow-slot map from scalar-specific `scalar_slot_shadow_slots` to `entry_alloca_shadow_slots` across `FnCtx` and all scalar-replacement and non-scalar callers. Rename `emit_scalar_slot_store_barrier` to `emit_entry_alloca_store_barrier` and update every call site, including `root_entry_alloca`, without changing behavior.scripts/gc_root_dominance_check.py (2)
1185-1229: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the duplicated "GC-capable alloca" predicate.
The same alloca-classification loop (
ALLOCA_RE.match+any(... .startswith(t) for t in GC_CAPABLE_ALLOCA_TYPES)) appears three times: insidecheck_func_unrooted_allocas(Lines 862-865), inside_scan_unrooted(Lines 986-989), and again here in the CLI branch (Lines 1195-1198). Extract one helper, for exampleis_gc_capable_alloca(ins), and call it from all three sites. This keeps the reported alloca population and the scanned population from drifting apart if the classification rule changes later.♻️ Proposed extraction
+def is_gc_capable_alloca(ins): + am = ALLOCA_RE.match(ins.text) + return bool(am) and any( + am.group(2).strip().startswith(t) for t in GC_CAPABLE_ALLOCA_TYPES + ), (am.group(1) if am else None) + def check_func_unrooted_allocas(module, f, want_moving_only=False, poll_reaching=frozenset()): ... - am = ALLOCA_RE.match(ins.text) - if am and any(am.group(2).strip().startswith(t) - for t in GC_CAPABLE_ALLOCA_TYPES): - allocas[am.group(1)] = ins + is_capable, reg = is_gc_capable_alloca(ins) + if is_capable: + allocas[reg] = insApply the same substitution in
_scan_unrootedand in the--unrooted-allocasCLI branch.🤖 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 1185 - 1229, Extract the shared alloca classification logic into an `is_gc_capable_alloca(ins)` helper using `ALLOCA_RE` and `GC_CAPABLE_ALLOCA_TYPES`. Replace the duplicated predicates in `check_func_unrooted_allocas`, `_scan_unrooted`, and the `--unrooted-allocas` CLI counting loop with calls to this helper, preserving the existing classification behavior.
895-940: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueShare
window_hitsbetween the two checks.
check_funcandcheck_func_unrooted_allocasboth define the same collecting-call window helper before using it. Move it out, or make both functions use a shared module-level helper, so this CFG query cannot drift between check modes.🤖 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 895 - 940, Extract the duplicated window_hits logic from check_func and check_func_unrooted_allocas into one shared helper, then update both checks to call it while preserving the existing collecting-call and CFG traversal behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-codegen/src/lower_call/new.rs`:
- Around line 1144-1153: Update the construction_runs_user_code predicate
governing instance_root and this_slot rooting to also treat imported
constructors as effectful when ctx.imported_class_ctors.get(class_name)
indicates an imported class constructor. Preserve the existing checks for
class.constructor, declared fields, and extends metadata, and ensure direct new
and ancestor super dispatches root this before any constructor code can run.
In `@crates/perry-runtime/src/object/alloc.rs`:
- Around line 1144-1166: Snapshot the source string’s characters before entering
the allocation-heavy loop in js_object_assign_one, so chars() no longer borrows
heap bytes across allocations. Use the existing source conversion around
ptr/blen and mirror the expandos snapshot approach, then iterate over the owned
character data while preserving the current target, key, value, and write-funnel
handling.
In `@scripts/gc_root_dominance_check.py`:
- Around line 1104-1113: Add a reverse self-test assertion alongside the
existing _scan_unrooted([planted]) check: run the bind-anchored _scan against
the unrooted/rooted fixture collection and fail the self-test if any matches are
returned. Preserve the existing failure-reporting style, ensuring properly bound
allocas in those fixtures produce zero bind-anchored findings.
---
Nitpick comments:
In `@crates/perry-codegen/src/expr/index_set.rs`:
- Around line 1505-1508: In the affected index-set lowering arm, explicitly
release the key temporary-root guard before releasing the receiver guard. Update
the cleanup sequence around reread_store_operand and the existing recv_guard
release so key_guard is released first, preserving inner-to-outer order and
matching the sibling proxy_reflect path.
In `@crates/perry-codegen/src/expr/scalar_slot_root.rs`:
- Around line 102-145: Rename the generalized entry-alloca shadow-slot map from
scalar-specific `scalar_slot_shadow_slots` to `entry_alloca_shadow_slots` across
`FnCtx` and all scalar-replacement and non-scalar callers. Rename
`emit_scalar_slot_store_barrier` to `emit_entry_alloca_store_barrier` and update
every call site, including `root_entry_alloca`, without changing behavior.
In `@scripts/gc_root_dominance_check.py`:
- Around line 1185-1229: Extract the shared alloca classification logic into an
`is_gc_capable_alloca(ins)` helper using `ALLOCA_RE` and
`GC_CAPABLE_ALLOCA_TYPES`. Replace the duplicated predicates in
`check_func_unrooted_allocas`, `_scan_unrooted`, and the `--unrooted-allocas`
CLI counting loop with calls to this helper, preserving the existing
classification behavior.
- Around line 895-940: Extract the duplicated window_hits logic from check_func
and check_func_unrooted_allocas into one shared helper, then update both checks
to call it while preserving the existing collecting-call and CFG traversal
behavior.
🪄 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: 295f5e7b-00e2-47a9-8038-1ce1fb8c9376
📒 Files selected for processing (17)
changelog.d/7207-residual-root-store-holes.mdcrates/perry-codegen/src/expr/index_set.rscrates/perry-codegen/src/expr/logical_collections.rscrates/perry-codegen/src/expr/mod.rscrates/perry-codegen/src/expr/property_set.rscrates/perry-codegen/src/expr/proxy_reflect.rscrates/perry-codegen/src/expr/scalar_slot_root.rscrates/perry-codegen/src/expr/static_field_meta.rscrates/perry-codegen/src/expr/temp_root.rscrates/perry-codegen/src/lower_call/new.rscrates/perry-codegen/tests/temp_root_operand_temporaries.rscrates/perry-runtime/src/object/alloc.rsscripts/gc_root_dominance_check.pytest-files/test_gap_gc_inline_ctor_this_rooting.tstest-files/test_gap_gc_spread_accessor_rooting.tstest-files/test_gap_gc_static_block_this_rooting.tstest-parity/gc_repsel_corpus.txt
| # And it must not fire on the bind-anchored fixtures, nor the reverse: | ||
| # the two populations are disjoint by construction and a checker that | ||
| # double-counts would make both numbers meaningless. | ||
| found, _ = _scan_unrooted([planted]) | ||
| if found: | ||
| print(f"self-test FAIL: the bind-anchored planted fixture has a " | ||
| f"bind for every alloca, so the unrooted check must report 0, " | ||
| f"got {len(found)}", file=sys.stderr) | ||
| ok = False | ||
|
|
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add the missing "reverse" self-test assertion.
The comment states the two checker modes are disjoint "in both directions," but only one direction is asserted: _scan_unrooted([planted]) must report 0. There is no assertion that _scan (the bind-anchored check) reports 0 on the new unrooted/rooted fixtures. rooted.ll in particular contains a store → bind → collecting-call → load sequence, the same general shape the bind-anchored check inspects, so a regression there — the bind-anchored check firing on a properly-bound alloca — would go undetected by self_test().
Add the reverse assertion so the disjointness claim in the comment is actually verified.
✅ Proposed additional assertions
found, _ = _scan_unrooted([planted])
if found:
print(f"self-test FAIL: the bind-anchored planted fixture has a "
f"bind for every alloca, so the unrooted check must report 0, "
f"got {len(found)}", file=sys.stderr)
ok = False
+
+ found, _ = _scan([unrooted], False, "alloc")
+ if found:
+ print(f"self-test FAIL: the unrooted fixture has no bind at all, "
+ f"so the bind-anchored check must report 0, "
+ f"got {len(found)}", file=sys.stderr)
+ ok = False
+
+ found, _ = _scan([rooted], False, "alloc")
+ if found:
+ print(f"self-test FAIL: the rooted fixture's bind precedes the "
+ f"collecting call, so the bind-anchored check must report 0, "
+ f"got {len(found)}", file=sys.stderr)
+ ok = False📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| # And it must not fire on the bind-anchored fixtures, nor the reverse: | |
| # the two populations are disjoint by construction and a checker that | |
| # double-counts would make both numbers meaningless. | |
| found, _ = _scan_unrooted([planted]) | |
| if found: | |
| print(f"self-test FAIL: the bind-anchored planted fixture has a " | |
| f"bind for every alloca, so the unrooted check must report 0, " | |
| f"got {len(found)}", file=sys.stderr) | |
| ok = False | |
| # And it must not fire on the bind-anchored fixtures, nor the reverse: | |
| # the two populations are disjoint by construction and a checker that | |
| # double-counts would make both numbers meaningless. | |
| found, _ = _scan_unrooted([planted]) | |
| if found: | |
| print(f"self-test FAIL: the bind-anchored planted fixture has a " | |
| f"bind for every alloca, so the unrooted check must report 0, " | |
| f"got {len(found)}", file=sys.stderr) | |
| ok = False | |
| found, _ = _scan([unrooted], False, "alloc") | |
| if found: | |
| print(f"self-test FAIL: the unrooted fixture has no bind at all, " | |
| f"so the bind-anchored check must report 0, " | |
| f"got {len(found)}", file=sys.stderr) | |
| ok = False | |
| found, _ = _scan([rooted], False, "alloc") | |
| if found: | |
| print(f"self-test FAIL: the rooted fixture's bind precedes the " | |
| f"collecting call, so the bind-anchored check must report 0, " | |
| f"got {len(found)}", file=sys.stderr) | |
| ok = False |
🤖 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 1104 - 1113, Add a reverse
self-test assertion alongside the existing _scan_unrooted([planted]) check: run
the bind-anchored _scan against the unrooted/rooted fixture collection and fail
the self-test if any matches are returned. Preserve the existing
failure-reporting style, ensuring properly bound allocas in those fixtures
produce zero bind-anchored findings.
Verification updateFull runtime + codegen suites, base vs this PR — identical
The symmetric difference of the two failing-test-name sets is empty. The 31 are the pre-existing set, and each family is accounted for:
The two lint gates, also identical at baseRun on a pristine
Neither over-length file nor the addr-class site is touched by this PR; every file it adds to or creates is comfortably under the cap (largest: Since the PR was opened
Follow-ups filed from the enumerationThe enumeration is the deliverable #7202 asked for, so its remaining HAZARD sites are now tracked rather than living in a PR body: #7208 (closure Note on overlap with #7206Both PRs touch |
|
Thanks — the major finding is real and is now fixed, and it was pre-existing rather than introduced here, which is worth saying precisely because it changes who else it affects.
|
gc-ratchet, run locally on macOS — gated and ungated columns#7205 reports that
The two gated row sets are identical — empty symmetric difference, verified row-by-row on Why it is red, and why that is not this PR's problem to fixEvery gated regression is one of That is the signature #7205 already names: the pinned baseline ( Ungated column
|
The
|
| arm | --test native_proof_regressions -- --test-threads=1 |
|---|---|
origin/main 3a983ca6a |
235 passed; 16 failed |
this PR (e1bff3a57) |
235 passed; 16 failed |
Identical, to the test. The 16 are the invalidation::* set already present in the 31-name comparison; the ~50 extra names in the parallel run are the flake, and they appear or not depending on interleaving, on both trees.
This is worth stating plainly because the flake is large enough to swamp a real regression: a 50-name swing between two runs of the same tree means this binary cannot distinguish a broken PR from a lucky one under --no-fail-fast parallelism. That is a gate-quality problem in its own right (the family runs nightly/at-tag rather than per-PR, which is how it stayed this way), and it is not this PR's to fix — but anyone comparing suite output across arms should pin --test-threads=1 for this binary or they will chase ghosts.
Matrix,
|
| arm | md5 | output |
|---|---|---|
origin/main 3a983ca6a |
6dd48424… |
[1,null] 9 8 2 / 1341.5 / 11 |
this PR e1bff3a57 |
a2418263… |
[1,null] 9 8 2 / 1341.5 / 11 |
node --experimental-strip-types 26.5.1 |
— | [1,null] 9 8 2 / 1341.5 / 11 |
Byte-identical on both arms and against the oracle. Not caused by this change.
The three new rows
All three register and behave exactly as their manifest entries claim — PASS on every arm that bit, UNVER on the arms that were inert, no FAIL anywhere:
gc_spread_accessor_rooting UNVER PASS PASS UNVER PASS PASS PASS PASS PASS PASS UNVER UNVER PASS … PASS
gc_static_block_this_rooting UNVER PASS PASS UNVER PASS PASS PASS PASS PASS PASS UNVER UNVER PASS … PASS
gc_inline_ctor_this_rooting UNVER PASS PASS UNVER PASS PASS PASS PASS PASS PASS UNVER UNVER PASS … PASS
Arm liveness confirms the requires=move arms were live on 31 of 32 rows (collected 31/32, moved-objects 31/32, copy-minor 31/32), so those PASSes are not inert cells wearing a green hat.
The checker A/B on the gc-root-dominance workflow corpus (44 .ll, same glob CI uses)
Both corpora built with PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_INLINE_SHADOW_SLOT=0, then scanned with this branch's checker so the only variable is the compiler.
| check | base 3a983ca6a |
this PR |
|---|---|---|
bind-anchored, --moving-only (the shipped gate) |
5 | 5 |
--unrooted-allocas, all |
174 | 171 |
--unrooted-allocas, --moving-only |
29 | 28 |
The moving-reachable unrooted-alloca count drops by exactly one — the inline-ctor this slot — and the total by three. The bind-anchored number is unchanged at 5, so this PR moves neither the shipped gate's verdict nor its population.
Two consequences worth acting on separately
gc-root-dominanceis red onmainat 5 moving violations, all anchored onjs_object_alloc. That gate is deliberately not a required context yet (fix(codegen): close #7192's own residual root-store hole, and make the dominance checker able to fail #7198 said "promote after a clean week"); on this measurement the week has not started. Not this PR's to fix, but the promotion plan needs it.--unrooted-allocasis a diagnostic, not a gate, and this measurement is why I am saying so rather than wiring it into the workflow: 28 moving-reachable hits remain after this change, and they are the GC: the remaining unrooted-alloca hazards after #7207 — class-keys pointer caches, interleaved staging arrays, inlined-callee param slots #7210 population (119 of the 171 total are inmain, i.e. the module-init frame's class-keys caches and staging arrays). Promoting it now would make the workflow red for reasons this PR does not own. It becomes gateable when GC: a closure's capturedthis/new.targetslots are unrooted allocas (closure.rs reserves no +1 slot where method.rs does) #7208/GC: thecatch (e)parameter slot is reserved but never bound — the exception has no root across the catch body #7209/GC: the remaining unrooted-alloca hazards after #7207 — class-keys pointer caches, interleaved staging arrays, inlined-callee param slots #7210 land, and the number above is the baseline to drive to zero.
…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]`).
…riant writeup Merge-time corrections to statements that PerryTS#7207 and PerryTS#7196 invalidated while this PR was in review: - CLAUDE.md called `lower_call/new.rs`'s inline-ctor `this_slot` "still open". PerryTS#7207 closed it. Point at `--unrooted-allocas` as the detector for that shape and name PerryTS#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 yet 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.
…eir sibling operands (#7206) * fix(codegen): root a call receiver and a computed-read base across their sibling operands Two more sites of #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 #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 #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 (#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 #7161's stopgap stays. Its default arm is clean 10/10. cargo test -p perry-codegen: the 6 loop_safepoint_purity failures from #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 #7154, #7192, #7198, #7184, #7161, #7114, #6951. * fix(codegen): adopt #7207's reread_store_operand signature at the two #7206 sites Merge-time fix, not a change of intent. #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 #7207's strictly better treatment of a literal base (`"abc"[k]`). * test(gc): register #7206's two witnesses in the GC x repsel corpus 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. --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…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`.
…riant writeup Merge-time corrections to statements PerryTS#7207 invalidated while this PR was in review: - CLAUDE.md called `lower_call/new.rs`'s inline-ctor `this_slot` "still open". PerryTS#7207 closed it. Point at `--unrooted-allocas` as the detector for that shape and name PerryTS#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.
…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>
The three residual sites of #7192's class, each taken to a reproducer or to the emitted IR before it was accepted, and two of the three issues' stated causes were wrong — both are corrected below with evidence.
The invariant
A GC-managed value's root store must DOMINATE every subsequent site that can trigger a collection, and the slot it is stored into must be one the collector actually rewrites.
#7184 broke it with a slot index outside the pushed frame. #7192 with a store emitted after an allocating call. These three break it three further ways, and #7202 is indeed the general shape: a heap value in storage the collector never rewrites, or a register never re-read below the collection point.
#7201 — the
PutValueSetkey, not the static-thiscellThe issue's premise is wrong and I am saying so rather than fixing something else quietly.
STATIC_THIS_OVERRIDE(object/this_binding.rs:38) is already a marked-and-rewritten root:scan_implicit_this_roots_mutvisits it atthis_binding.rs:212-217withvisit_nanbox_u64_slot(mark and write-back) and is registered atgc/mod.rs:523. The static-block body'sthisalloca is shadow-bound too (codegen/method.rs:1385-1390, theenable_shadow_frame(m.len() + 1)slot). "Register the cell as a root" would have been a no-op.The emitted IR names the real one.
(this as any).viaBlock = churn()lowers through the#6812dynamic-key write IC:The comment at the lowering claimed this path was "GC-clean with NO compile-time value gate … a moved key merely misses by stale bits (identity compare — false negatives only)". That is true of the three way-compares and false of everything below them: the miss falls through to
put.dynic.slow, which hands the same register tojs_put_value_set_dyn_ic, which dereferences it as aStringHeader*. A string literal's__perry_init_strings_*handle is a registered root that evacuation rewrites — #7114 exactly — so the register names from-space.An identity-compare-only use is not the only use. The key is now re-derived below the value on the inline arm, and both the receiver and the key on the two outlined arms (where
tis lowered first and is live across both siblings).The sibling hole this exposed
reread_store_operand'sReloadarm returned the caller's register unchanged, reasoning that "for a literal [the register] is a load from that same global". It is a load from that global taken before the collection point.RootedOperands::reread— the call-operand sibling — has always re-lowered itsReloadoperands (temp_root.rs:523). Two helper families answering the same question differently is the drift that produced #7114; they now agree. Also addedguard_store_operand_across, because a receiver lowered before both the key and the value is live across both, and every caller derivedcollectsfrom the value alone — soo[f()] = 1was unguarded.#7200 —
js_object_assign_one, not theExpr::ObjectAssignaccumulatorAlso a corrected premise.
{ ...src, tail: 7 }does not reachExpr::ObjectAssign: the #809 IIFE emits a genericExpr::Callonjs_object_assign_one(lower/expr_object.rs:1159-1163) whose__ois aType::Anylocal with a shadow slot — marked and rewritten. That is why the lighter variant reportsplain 0 hot 10 tail 0: the destination object is fine.The SIGSEGV is inside the helper.
js_object_get_field_by_nameshort-circuits intoinvoke_accessor_getter→js_closure_call0— arbitrary user code inside a runtime helper — andtarget,src,src_keysandkey_ptrare raw addresses in Rust locals, used after it returns.targetandkey_ptrby the write funnel on the very next line;src_keys/srcby the next iteration. The function already modelled the fix one branch up: the native-module arm opens aRuntimeHandleScope, rootstarget, and returns the handle-reloaded pointer. That treatment now covers both numbered sections, the array-source branch, the proxy-source loop (two traps per key — the widest window in the file), the string-source loop, the symbol tail, and the getter's own return value. The function returns the post-collection target instead of thetarget_f64captured on entry.The codegen
accthreading is a separate, real bug —Object.assign(t, f(), g())— and is fixed with it: the accumulator is temp-rooted, re-read before every link, and republished after each, because the helper now returns a moved address.#7202 — the enumeration, and the
this_slotfixFull enumeration in the changelog fragment: 140 bare-alloca sites (63
alloca_entry, 42alloca_entry_array, 2alloca_entry_bytes_aligned, 19blk.alloca, 14 raw), 23 HAZARD / 117 SAFE, each with its disposition.Fixed here: the inline-constructor
this_slot(lower_call/new.rs:1109).force_ctor_callrequiresclass.constructor.is_some(), soclass C { payload = mk() }andclass C extends B {}take this path withPERRY_INLINE_CTORunset — andconstruction_runs_user_code, which gates theinstance_root#7192 added, is true for exactly those. The function already asserts this window collects, and then keeps a second copy of the same address one line later that nothing rewrites.Binding it (rather than routing
Expr::Thisthrough a temp root) leaves all ~30ctx.this_stack.last()readers untouched: they alreadyloadfrom the alloca, andjs_shadow_slot_bindrecordsslot_ptrs[idx] = alloca, so evacuation rewrites it in place.expr/scalar_slot_root.rs's #6968 machinery is generalized toroot_entry_allocafor it, contract and all (seedundefinedinentry_allocas; bind hoisted to entry setup; barrier at the store).There is no runtime reproducer for #7202 and I am not implying otherwise.
test_gap_gc_inline_ctor_this_rooting.tsis green at base: the class-field inline guard readsobj_type/class_id/keys_arrayoff the stale pointer, the evacuation's forwarding record fails that guard, and the runtime fallback resolves forwarding — so the stale write currently lands correctly. That masking holds only while the from-space block still carries the forwarding record, i.e. exactly #7154's latency. It rests on the static argument, which is now gated two ways: a codegen regression test, and a new checker mode.The instrument:
--unrooted-allocasThe shipped check anchors on
js_shadow_slot_bind, so a value that is never bound produces no triple and the function reports clean. The new mode checks the complementary shape: a gc-capable alloca never named by a bind, holding a value whose provenance is a heap source, loaded below a collecting call on a real CFG path. It foundthis_slotindependently.A/B on the reproducer's IR,
PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_INLINE_SHADOW_SLOT=0:__mk, thethisslot)The survivor is
function.rs:521'sperry_class_keys_*cache — a real hazard from the enumeration (§1d), not moving-reachable on this corpus, left for its own change.--self-testgrew four assertions for it, in both directions: the planted fixture must report exactly 1, the control — byte-identical but for the bind — must report 0, and the mode must not double-count the bind-anchored fixtures.Verification
3a983ca6a)test_gap_gc_spread_accessor_rooting,POLLS=1bad plain 0 hot 0 tail 05/5test_gap_gc_static_block_this_rooting,POLLS=1bad 43/3 (deterministic)bad 05/5test_gap_gc_inline_ctor_this_rooting,POLLS=1bad first 0 …(masked — see above)bad first 0 …5/5node --experimental-strip-types26.5.1--unrooted-allocas, moving-reachableEvery new codegen test was sabotage-checked in both directions, each conjunct separately: removing the bind reddens it; removing the
undefinedseed reddens it independently; removing the key re-read reddens the #7201 test; removing the accumulator re-read reddens the #7200 test.All three gap files are registered in
test-parity/gc_repsel_corpus.txtwith their measured base behaviour and an explicit note that they arerequires=move-only today and flip to PASS on the other arms when #7161 is reverted.#7161 revert readiness
Not yet, and the enumeration is why. Two of the three named blockers are closed with runtime witnesses; the third is closed statically, with its masking stated rather than papered over.
What the enumeration then surfaced is that the three named blockers were not the whole set. Twenty further HAZARD sites have never been exercised under a moving minor, and three of them are as load-bearing as the ones this PR fixes:
this/new.targetslots are unrooted allocas (closure.rs reserves no +1 slot where method.rs does) #7208 — a closure's capturedthis/new.targetslots.codegen/closure.rs:589callsenable_shadow_frame(m.len())wheremethod.rs:316/:1344callm.len() + 1; that+1is thethisslot, so closures reserve no index to bind and the receiver is unrooted for the whole body.catch (e)parameter slot is reserved but never bound — the exception has no root across the catch body #7209 — thecatch (e)parameter slot, which is reserved and never bound (pointer_locals.rs:983-990sizes the frame for it;try_stmt.rs:114never emits the bind), withjs_clear_exceptiondropping the runtime's own reference before the body runs.perry_class_keys_*pointer caches (whose original is a registered global root precisely because C4b can relocate it), nine interleaved staging arrays, the inlined-callee param slots that are structurally unrootable today, and four typed-array data-pointer caches that are safe only while no admitted construction form nursery-allocates.Those should be closed, and
sfw-registry --helpre-measured underPERRY_GC_MOVING_LOOP_POLLS=1, before the flip.gc_root_dominance_check.py --unrooted-allocasis now the instrument that finds them without waiting for a crash, which is the part of this that outlasts the three fixes.Refs #7200, #7201, #7202, #7154, #7161, #7192, #7198, #7184, #7114, #6968, #6951.
Summary by CodeRabbit
Bug Fixes
Object.assign, object spread, dynamic property writes, and accessor calls.Tests