Skip to content

fix(gc): close the three residual root-store holes — the PutValueSet key, js_object_assign_one, and the inline-ctor this slot - #7207

Merged
proggeramlug merged 8 commits into
mainfrom
fix/7202-gc-alloca-roots
Aug 1, 2026
Merged

fix(gc): close the three residual root-store holes — the PutValueSet key, js_object_assign_one, and the inline-ctor this slot#7207
proggeramlug merged 8 commits into
mainfrom
fix/7202-gc-alloca-roots

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

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 PutValueSet key, not the static-this cell

The 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_mut visits it at this_binding.rs:212-217 with visit_nanbox_u64_slot (mark and write-back) and is registered at gc/mod.rs:523. The static-block body's this alloca is shadow-bound too (codegen/method.rs:1385-1390, the enable_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 #6812 dynamic-key write IC:

%r9  = load double, ptr @…_.str.2.handle          ; the KEY literal — loaded ABOVE
%r10 = call double @perry_fn_…__churn()            ; user code -> back-edge poll -> evacuating minor
%r11 = load double, ptr %r7                        ; `this` re-read — correct
%r12 = bitcast double %r9 to i64                   ; the STALE key
…
put.dynic.slow.7:
  %r99 = call double @js_put_value_set_dyn_ic(ptr @perry_ic_8, double %r11, double %r9, …)

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 to js_put_value_set_dyn_ic, which dereferences it as a StringHeader*. 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 t is lowered first and is live across both siblings).

The sibling hole this exposed

reread_store_operand's Reload arm 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 its Reload operands (temp_root.rs:523). Two helper families answering the same question differently is the drift that produced #7114; they now agree. Also added guard_store_operand_across, because a receiver lowered before both the key and the value is live across both, and every caller derived collects from the value alone — so o[f()] = 1 was unguarded.


#7200js_object_assign_one, not the Expr::ObjectAssign accumulator

Also a corrected premise. { ...src, tail: 7 } does not reach Expr::ObjectAssign: the #809 IIFE emits a generic Expr::Call on js_object_assign_one (lower/expr_object.rs:1159-1163) whose __o is a Type::Any local with a shadow slot — marked and rewritten. That is why the lighter variant reports plain 0 hot 10 tail 0: the destination object is fine.

The SIGSEGV is inside the helper. js_object_get_field_by_name short-circuits into invoke_accessor_getterjs_closure_call0arbitrary user code inside a runtime helper — and target, src, src_keys and key_ptr are raw addresses in Rust locals, used after it returns. target and key_ptr by the write funnel on the very next line; src_keys/src by the next iteration. The function already modelled the fix one branch up: the native-module arm opens a RuntimeHandleScope, roots target, 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 the target_f64 captured on entry.

The codegen acc threading 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_slot fix

Full enumeration in the changelog fragment: 140 bare-alloca sites (63 alloca_entry, 42 alloca_entry_array, 2 alloca_entry_bytes_aligned, 19 blk.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_call requires class.constructor.is_some(), so class C { payload = mk() } and class C extends B {} take this path with PERRY_INLINE_CTOR unset — and construction_runs_user_code, which gates the instance_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::This through a temp root) leaves all ~30 ctx.this_stack.last() readers untouched: they already load from the alloca, and js_shadow_slot_bind records slot_ptrs[idx] = alloca, so evacuation rewrites it in place. expr/scalar_slot_root.rs's #6968 machinery is generalized to root_entry_alloca for it, contract and all (seed undefined in entry_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.ts is green at base: the class-field inline guard reads obj_type/class_id/keys_array off 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-allocas

The 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 found this_slot independently.

A/B on the reproducer's IR, PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_INLINE_SHADOW_SLOT=0:

base this PR
unrooted-alloca violations 2 1
moving-minor reachable 1 (__mk, the this slot) 0

The survivor is function.rs:521's perry_class_keys_* cache — a real hazard from the enumeration (§1d), not moving-reachable on this corpus, left for its own change.

--self-test grew 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

base (3a983ca6a) this PR
test_gap_gc_spread_accessor_rooting, POLLS=1 exit=139 SIGSEGV 3/3 bad plain 0 hot 0 tail 0 5/5
test_gap_gc_static_block_this_rooting, POLLS=1 bad 4 3/3 (deterministic) bad 0 5/5
test_gap_gc_inline_ctor_this_rooting, POLLS=1 bad first 0 … (masked — see above) bad first 0 … 5/5
all three, shipped default (no polls) clean clean 3/3
all three vs node --experimental-strip-types 26.5.1 byte-exact
--unrooted-allocas, moving-reachable 1 0

Every new codegen test was sabotage-checked in both directions, each conjunct separately: removing the bind reddens it; removing the undefined seed 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.txt with their measured base behaviour and an explicit note that they are requires=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:

Those should be closed, and sfw-registry --help re-measured under PERRY_GC_MOVING_LOOP_POLLS=1, before the flip. gc_root_dominance_check.py --unrooted-allocas is 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

    • Fixed rare garbage-collection issues that could cause incorrect values or lost object state during Object.assign, object spread, dynamic property writes, and accessor calls.
    • Improved reliability for class instances with allocating constructors, field initializers, or static initialization blocks.
    • Preserved object properties, getter results, symbols, and array contents during moving garbage collection.
  • Tests

    • Added regression and stress coverage for these garbage-collection scenarios.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a755227-6f5b-45fd-8e9f-5645d30e75a8

📥 Commits

Reviewing files that changed from the base of the PR and between e1bff3a and 5e613b4.

📒 Files selected for processing (1)
  • changelog.d/7207-residual-root-store-holes.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • changelog.d/7207-residual-root-store-holes.md

📝 Walkthrough

Walkthrough

The PR fixes moving-GC root-store holes in dynamic property writes, Object.assign, and inline constructors. It adds operand rereads, shadow-slot rooting, an unrooted-alloca checker mode, runtime and codegen regression tests, corpus entries, and a 140-site alloca audit.

Changes

Moving-GC rooting

Layer / File(s) Summary
Rooting and reread infrastructure
crates/perry-codegen/src/expr/temp_root.rs, crates/perry-codegen/src/expr/scalar_slot_root.rs, crates/perry-codegen/src/expr/mod.rs
Store guards classify reloadable operands. Rereads can re-lower expressions and return errors. Entry allocas use cached shadow-slot bindings and per-store barriers.
GC-safe property and assignment lowering
crates/perry-codegen/src/expr/index_set.rs, crates/perry-codegen/src/expr/property_set.rs, crates/perry-codegen/src/expr/proxy_reflect.rs, crates/perry-codegen/src/expr/static_field_meta.rs, crates/perry-codegen/src/expr/logical_collections.rs, crates/perry-codegen/tests/temp_root_operand_temporaries.rs
Dynamic keys, receivers, and targets are rooted and reread across allocation windows. Object.assign republishes its rooted accumulator after each helper call.
Constructor this-slot rooting
crates/perry-codegen/src/lower_call/new.rs, crates/perry-codegen/tests/temp_root_operand_temporaries.rs, test-files/test_gap_gc_inline_ctor_this_rooting.ts, test-files/test_gap_gc_static_block_this_rooting.ts
Inline constructors bind and initialize the instance slot as a shadow root when user code can run. Regression tests cover relocation and the collection-free path.
Runtime Object.assign handle management
crates/perry-runtime/src/object/alloc.rs, test-files/test_gap_gc_spread_accessor_rooting.ts
String, proxy, closure, array, object, and symbol-copy paths root live values and return relocated targets after allocations, getters, or proxy traps.
Unrooted alloca analysis and validation
scripts/gc_root_dominance_check.py, changelog.d/7207-residual-root-store-holes.md, test-parity/gc_repsel_corpus.txt
The dominance checker adds unrooted-alloca detection, self-tests, moving-only filtering, CLI reporting, and a 140-site audit. Regression coverage is registered for moving-GC runs.

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
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7198 — Extends the same GC root-store fixes in overlapping codegen and checker paths.
  • PerryTS/perry#7192 — Shares GC-rooting changes across codegen and dominance-checker paths.
  • PerryTS/perry#6983 — Overlaps in temporary-root infrastructure, constructor lowering, and regression tests.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the three residual GC root-store hazards addressed by the pull request.
Description check ✅ Passed The description thoroughly covers the changes, rationale, affected issues, verification results, and remaining hazards.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7202-gc-alloca-roots

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (4)
crates/perry-codegen/src/expr/index_set.rs (1)

1505-1508: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Release key_guard explicitly to match the sibling store paths.

This arm pushes key_guard after recv_guard, so its slot index is higher. Line 1544 releases only recv_guard, and temp_root_truncate drops 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.rs at 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 value

Consider renaming the scalar-specific identifiers now that the helper is general.

root_entry_alloca documents three non-scalar callers: the inline-constructor this_slot, closure captured this / new.target slots, and a catch (e) parameter slot. The cache map is still ctx.scalar_slot_shadow_slots and the barrier helper is still emit_scalar_slot_store_barrier. Both names now describe only one of several callers, so a reader of lower_call/new.rs sees a scalar-replacement map used for a constructor slot.

Rename to something scope-accurate, for example entry_alloca_shadow_slots and emit_entry_alloca_store_barrier. The map lives on FnCtx, 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 win

Extract 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: inside check_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 example is_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] = ins

Apply the same substitution in _scan_unrooted and in the --unrooted-allocas CLI 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 value

Share window_hits between the two checks.

check_func and check_func_unrooted_allocas both 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

📥 Commits

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

📒 Files selected for processing (17)
  • changelog.d/7207-residual-root-store-holes.md
  • crates/perry-codegen/src/expr/index_set.rs
  • crates/perry-codegen/src/expr/logical_collections.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/property_set.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/expr/scalar_slot_root.rs
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/tests/temp_root_operand_temporaries.rs
  • crates/perry-runtime/src/object/alloc.rs
  • scripts/gc_root_dominance_check.py
  • test-files/test_gap_gc_inline_ctor_this_rooting.ts
  • test-files/test_gap_gc_spread_accessor_rooting.ts
  • test-files/test_gap_gc_static_block_this_rooting.ts
  • test-parity/gc_repsel_corpus.txt

Comment thread crates/perry-codegen/src/lower_call/new.rs
Comment thread crates/perry-runtime/src/object/alloc.rs
Comment on lines +1104 to +1113
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

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.

Suggested change
# 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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Verification update

Full runtime + codegen suites, base vs this PR — identical

cargo test --release -p perry-runtime -p perry-codegen --no-fail-fast, both arms on the same quiet host (Mac mini, 8 core), isolated CARGO_TARGET_DIR per arm, hashed binaries.

origin/main 3a983ca6a this PR
failing test binaries 6 6
unique failing test names 31 31
only-in-PR (regressions) none
only-in-base (newly passing) none

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 base

Run on a pristine origin/main worktree and on this branch on the same host:

gate base this PR
scripts/check_file_size.sh exit 1, 16 files over 2000 exit 1, the same 16
scripts/addr_class_inventory.py exit 1 (native_module/constants.rs:2094) exit 1, same site
cargo fmt --all -- --check clean clean

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: gc_root_dominance_check.py at 1273, temp_root_operand_temporaries.rs at 1269, object/alloc.rs at 1647). Both gates are red on main today — worth someone's attention independently of this PR, since Tests carries them.

Since the PR was opened

  • The this-slot bind is now gated on construction_runs_user_code — the same predicate that decides the instance needs a temp root at all. A class with no constructor, no fields and no heritage keeps its previous IR exactly, frame size included, and a new negative test asserts it (sabotage-checked: removing the gate reddens it).
  • js_object_assign_one's closure-source branch got the same treatment. It is the allocation-only form of the window rather than user-code re-entry (the snapshot is raw, no accessor runs), but a Vec of unrooted heap words held across js_string_from_bytes + the write funnel is a liveness hole as well as a staleness one.

Follow-ups filed from the enumeration

The enumeration is the deliverable #7202 asked for, so its remaining HAZARD sites are now tracked rather than living in a PR body: #7208 (closure this/new.target slots — closure.rs reserves no +1 where method.rs does), #7209 (the catch (e) slot, which is reserved and never bound, with js_clear_exception dropping the runtime's own reference first), #7210 (class-keys pointer caches, interleaved staging arrays, inlined-callee param slots, and the four conditionally-safe typed-array data-pointer caches).

Note on overlap with #7206

Both PRs touch scripts/gc_root_dominance_check.py#7206 adds --stale-registers (the general register invariant), this one adds --unrooted-allocas (the never-bound-storage invariant). They are complementary modes over disjoint populations and the conflict is textual only; whichever lands second rebases.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

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.

construction_runs_user_code and imported constructors — fixed

ctx.classes[class_name].constructor is None for an imported constructor, so a class that also declares no fields and no heritage answered false while lower_new_impl_inner went on to dispatch ctx.imported_class_ctors[class_name] (the has_imported_ctor arm at new.rs:1242, and the writer at :1748). The predicate now returns true for ctx.imported_class_ctors.contains_key(class_name).

Two things follow that the review comment does not:

  1. This was already a hole in fix(codegen): make every GC root store dominate the collection points after it #7192, not one this PR opened. The same predicate gates instance_root — so the caller's instance was unrooted across an imported constructor body too, which is exactly the GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154 shape fix(codegen): make every GC root store dominate the collection points after it #7192 set out to close.
  2. Fixing it in the predicate rather than at the this-slot site is deliberate. The suggestion was to "include an effectful imported_class_ctors check in the same predicate used by this path"; keeping it as one predicate is the whole design — the this-slot bind is gated on instance_root.is_some(), so the two consumers cannot diverge. Adding a second, this-slot-specific check is precisely the shape that let String literal operand is not GC-rooted across an allocating call in the same expression (stale handle after evacuation) #7114's two predicates drift apart.

index_set.rs guard release order — applied

Correct that the existing single release_store_operand(recv_guard) already drops the key slot, since temp_root_truncate cuts everything at and above its index. Made explicit anyway, with the reason in a comment: it is correct today and silently wrong the moment the push order changes, and the proxy_reflect sibling this PR adds spells both out.

scalar_slot_root.rs naming — noted, deliberately deferred

Agreed that scalar_slot_shadow_slots and the module name now under-describe what root_entry_alloca does. Renaming the FnCtx field touches several files for no behavioural change, and this PR is already carrying two runtime files and six codegen ones across three issues. It belongs with #7208/#7209, which are the next users of the generalized helper and will make the rename obviously right rather than speculative.

All 19 tests in temp_root_operand_temporaries pass after both changes, and the failing-test set is still byte-identical to origin/main's.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

gc-ratchet, run locally on macOS — gated and ungated columns

#7205 reports that gc-ratchet has executed zero times on main across three merges (queued runs cancelled), so its verdict on this PR would be unknown either way. Run here on the pinned host class the baseline was captured on (darwin-arm64, Apple M1, 8 core), idle, --repeats 7, both arms measured with the same harness on the same box, hashed binaries.

arm check --profile shared_ci REGRESSION rows
origin/main 3a983ca6a exit 1 69
this PR (e1bff3a57) exit 1 69

The two gated row sets are identical — empty symmetric difference, verified row-by-row on (probe, metric). So the gate is red on main today and this PR does not move it in either direction.

Why it is red, and why that is not this PR's problem to fix

Every gated regression is one of minor_cycles, step_cycles, copied_objects, copied_bytes, promoted_objects, promoted_bytes, freed_bytes reading … → 0, plus the heap_used_bytes / heap_total_bytes growth that follows from nothing being collected:

| `01_nursery_churn` | minor_cycles    | 14     | 0 | -100.00% | 1   | yes | REGRESSION |
| `01_nursery_churn` | copied_objects  | 18,961 | 0 | -100.00% | 948 | yes | REGRESSION |
| `01_nursery_churn` | freed_bytes     | 29,130,768 | 0 | -100.00% | 1,456,538 | yes | REGRESSION |

That is the signature #7205 already names: the pinned baseline (88dcee83b, captured 2026-07-30) predates #7161 flipping the evacuating minor off, so the ratchet is comparing a non-moving collector against a moving-collector baseline. It cannot check evacuation counters until the revert or a deliberate re-pin. Re-pinning it now would bake the stopgap into the baseline and make the counters dark for exactly the change they exist to gate; that belongs on the #7161 revert, not here.

Ungated column

--profile pinned_host (which additionally gates RSS and wall time, correct on this box because it is the machine class the baseline came from) reports 86 regression rows — the same 69 plus 17 rss_bytes/peak_rss_bytes rows, one pair per probe. Those follow mechanically from the same cause: with no minor collections the nursery is never swept, so RSS tracks the allocation total. wall_ms is ok on every probe (01_nursery_churn +2.26%, inside a 100 ms band) — this change costs no measurable time.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

The native_proof_regressions flake, measured rather than assumed

A second full-suite run of this branch reported 81 failing test names where the first reported 31, with ~50 apparently new — native_library::*, pod_manifest::*, scalar_method_*, packed_f64_loop_*, typed_f64_*, representation_first_*. All of them live in one binary, crates/perry-codegen/tests/native_proof_regressions, which #7184 already recorded as order-flaky on main ("verified by isolated re-runs passing on both trees").

Rather than cite that and move on, both arms were re-run in isolation, single binary, --test-threads=1, same host, hashed binaries:

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Matrix, --arms all --pressure 8, run IDLE

scripts/gc_repsel_matrix.sh --arms all --pressure 8, on the mini with load average 3.17 at start (#7198's finding that a contended run reported FAIL=70 against FAIL=10 idle is why this waited for the box).

summary: PASS=407  UNVER=254  XFAIL=1  FAIL=10     (32 corpus files x 21 arms)

All 10 FAILs are one row: repsel_p4a3_ptr_numarray, on evac_minor, force_evac, force_verify, rep_i32_off, rep_str_off, rep_str_static_off, rep_ptr_shape_off, rep_ptr_numarray_off, rep_spec_abi_off, rep_int_valued_off — i.e. #7194 exactly, the untriaged main red across the requires=move arms. The single XFAIL is the pre-existing repsel_ptr_shape_locals × rep_ptr_shape_off (#6976).

#7194 exonerated by in-place A/B

Same file, same arm env (PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off), both compilers on the same host, hashed:

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

  1. gc-root-dominance is red on main at 5 moving violations, all anchored on js_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.
  2. --unrooted-allocas is 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 in main, 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 captured this / new.target slots are unrooted allocas (closure.rs reserves no +1 slot where method.rs does) #7208/GC: the catch (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.

@proggeramlug
proggeramlug merged commit 1679e22 into main Aug 1, 2026
6 of 8 checks passed
@proggeramlug
proggeramlug deleted the fix/7202-gc-alloca-roots branch August 1, 2026 19:05
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…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]`).
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…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.
proggeramlug pushed a commit that referenced this pull request Aug 1, 2026
…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>
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…ration

- changelog fragment was PR-misnumbered `7207-`; PerryTS#7207 is a different, already
  merged change. Renamed to `7214-`. Content already referenced PerryTS#7206
  correctly and is unchanged.
- `cargo fmt --all -- --check` is a required check on `lint`; one hand-wrapped
  `roots.push` call needed re-wrapping. No behaviour change.
- Registered the three witnesses in the GC x repsel corpus, next to PerryTS#7206's
  pair. All three are moving-only: clean on the shipped default on both sides
  of the fix, so they belong with the `requires=move` rows and prove nothing
  on `default`.
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…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.
proggeramlug pushed a commit that referenced this pull request Aug 1, 2026
…e_callN (#7214)

* fix(codegen): root the callee, `this` and every argument of js_closure_callN

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- changelog fragment was PR-misnumbered `7207-`; #7207 is a different, already
  merged change. Renamed to `7214-`. Content already referenced #7206
  correctly and is unchanged.
- `cargo fmt --all -- --check` is a required check on `lint`; one hand-wrapped
  `roots.push` call needed re-wrapping. No behaviour change.
- Registered the three witnesses in the GC x repsel corpus, next to #7206's
  pair. All three are moving-only: clean on the shipped default on both sides
  of the fix, so they belong with the `requires=move` rows and prove nothing
  on `default`.

---------

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

* fix(codegen): root the callee, `this` and every argument of js_closure_callN

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- changelog fragment was PR-misnumbered `7207-`; #7207 is a different, already
  merged change. Renamed to `7214-`. Content already referenced #7206
  correctly and is unchanged.
- `cargo fmt --all -- --check` is a required check on `lint`; one hand-wrapped
  `roots.push` call needed re-wrapping. No behaviour change.
- Registered the three witnesses in the GC x repsel corpus, next to #7206's
  pair. All three are moving-only: clean on the shipped default on both sides
  of the fix, so they belong with the `requires=move` rows and prove nothing
  on `default`.

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

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

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

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

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

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant