Skip to content

gc: fix root-dominance phi false positives + 3 of 5 real hits, lower --max-unrooted to 2 (#7664) - #7724

Merged
proggeramlug merged 6 commits into
mainfrom
gc/7664-native-unrooted-residue
Aug 9, 2026
Merged

gc: fix root-dominance phi false positives + 3 of 5 real hits, lower --max-unrooted to 2 (#7664)#7724
proggeramlug merged 6 commits into
mainfrom
gc/7664-native-unrooted-residue

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes the checker half of #7664 and two of the four real hits underneath it. Re-verification found the population was 5 real hits, not 4 (an off-by-one against the prior triage — see below), and this PR fixes 3 of them plus both checker false positives, leaving 2 open as a follow-up rather than rushed.

What I verified vs. what I'm inferring

  • Checker fix (phi false positives): verified two ways. --self-test (deterministic, no build needed) passes, including two new sabotage-tested fixtures (phi_safe_edge must report 0, phi_hazard_edge — byte-identical except the safepoint moves onto the tainted edge — must report exactly 1; each was confirmed to fail when the corresponding fix is reverted). Also ran against the real native corpus (149 modules) on a build that included this fix: the four unmasked phi false positives from the prior snapshot are gone, nothing else changed.
  • static_dispatch.rs fix: verified against the real corpus (0 hits for test_gap_static_method_value_name_collision, down from 1) and a functional smoke test ((Lexer as any).lex(...) / Parser.parse still produce correct output, no crash).
  • builtin.rs (the other 2 unrooted:global hits): I wrote a fix for these independently, then discovered origin/main had moved to include fix(gc): root builtin.rs's constructor arguments across each other's lowering (#6986) #7719, which fixes the identical shape (module-global constructor argument held across a sibling argument's allocation) across a superset of lower_call/builtin.rs's arms via the same RootedGroup mechanism. I dropped my version in favor of it and rebased cleanly (no conflicts). I have not re-run the corpus against the exact post-rebase commit — the corpus run showing 2 residual hits (both unrooted:capture) used a build with my own now-dropped builtin.rs fix, not fix(gc): root builtin.rs's constructor arguments across each other's lowering (#6986) #7719's. Since fix(gc): root builtin.rs's constructor arguments across each other's lowering (#6986) #7719 covers the same shape with the same mechanism, I expect the same reading post-rebase, but this PR's own gc-root-dominance-statepoints CI run is the actual confirmation, not something I'm asserting here.
  • The 2 remaining unrooted:capture hits: diagnosed in detail (see the workflow comment and the changelog fragment), not fixed. js_closure_get_capture_bits's return value is never re-entered into either the RS4GC-tracked domain or a temp root by the generic "read a captured value" call sites — narrower than the original triage's "signature/ABI change" guess, but still a real slice of work (root_reload.rs's Facts only models loads as reloadable sources today, not calls). Filing as a follow-up issue rather than rushing it.

The checker fix

scripts/gc_root_dominance_check.py's chain (the untracked cast-closure a stale use is searched in) treated phi as unconditionally transparent — one tainted incoming edge blanket-tainted the phi's result, and a downstream use of that result was checked against any CFG path between source and use. That's sound for an ordinary register but not a phi, whose dynamic value depends on which edge was actually taken. All four false positives were the same &&/|| short-circuit join: the tainted edge never crosses a safepoint, the other edge does.

_cast_closure gained phi_all_edges: a phi joins chain only once every incoming edge is independently in it (a worklist retry on each operand's own arrival, so admission order doesn't matter). That deliberately gives up the single-tainted-edge-with-its-own-safepoint case; _phi_edge_hazard covers that separately, checking each edge's window against its own predecessor's terminator instead of the join.

Budget

gc-root-dominance-statepoints' --max-unrooted goes 8 → 2. The full accounting (all 9 hits a fresh corpus actually reads before this PR, why the prior "8" was already stale, and the diagnosis for both remaining hits) is in the workflow comment and the changelog fragment.

Test plan

  • python3 scripts/gc_root_dominance_check.py --self-test — clean, including two new fixtures each confirmed via a sabotaged copy of the checker to still be able to fail
  • cargo check -p perry-codegen — clean
  • Native corpus (149 modules) + checker, --statepoints --moving-only: 4 phi false positives gone, test_gap_static_method_value_name_collision hit gone, 2 unrooted:capture hits remain exactly as diagnosed (run predates the fix(gc): root builtin.rs's constructor arguments across each other's lowering (#6986) #7719 rebase — see above)
  • Functional smoke test of the static_dispatch.rs change (test_gap_static_method_value_name_collision.ts, no oracle diff run — output shape looked correct, not byte-compared against node)
  • Fresh corpus run against the exact post-rebase commit (not done — relying on this PR's own gc-root-dominance-statepoints CI run)
  • --lowering shadow corpus (attempted twice; both runs hit unrelated compile flakiness on a heavily-loaded, briefly disk-full shared box — did not get a clean read before wrap-up)

Summary by CodeRabbit

  • Bug Fixes

    • Improved static method calls involving dynamically created classes and factory-produced receivers.
    • Fixed issues where receiver values could be lost during argument evaluation or memory allocation.
    • Reduced false alarms in native garbage-collection safety checks while preserving detection of genuine hazards.
  • Documentation

    • Updated release documentation and troubleshooting details for native safety checks.
  • Chores

    • Updated the project version to 0.5.1420.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b09f1b7-4618-42d7-9243-b71ab29e7487

📥 Commits

Reviewing files that changed from the base of the PR and between d849ff5 and af745a3.

📒 Files selected for processing (1)
  • crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs

📝 Walkthrough

Walkthrough

The change adds phi-aware native statepoint hazard analysis and tests, roots receivers during static class-method dispatch, documents remaining unrooted captures, lowers the enforcement budget to 2, and updates the project version to 0.5.1420.

Changes

GC root analysis and dispatch rooting

Layer / File(s) Summary
Phi-aware statepoint hazard analysis
.github/workflows/gc-root-dominance.yml, scripts/gc_root_dominance_check.py, changelog.d/7724-native-unrooted-residue.md
The checker parses phi incoming edges, performs all-edge taint propagation, checks hazards on tainted predecessor edges, and adds safe and hazardous self-test fixtures. The workflow budget changes to 2.
Rooted static class-method dispatch
crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs, changelog.d/7724-native-unrooted-residue.md
Static dispatch adopts and rereads receivers across argument lowering, arms receiver-sensitive static this, and restores implicit this around method execution.
Version and release metadata
CLAUDE.md, Cargo.toml
The documented and workspace package versions change to 0.5.1420.

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

Sequence Diagram(s)

sequenceDiagram
  participant LLVMIR
  participant RootDominanceChecker
  participant StatepointFixtures
  LLVMIR->>RootDominanceChecker: provide phi incoming operands and predecessors
  RootDominanceChecker->>RootDominanceChecker: propagate taint across all incoming edges
  RootDominanceChecker->>RootDominanceChecker: evaluate predecessor-edge statepoint hazards
  StatepointFixtures->>RootDominanceChecker: run safe and hazardous phi-edge cases
  RootDominanceChecker-->>StatepointFixtures: report zero or one unrooted hazard
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7663 — Introduced the native statepoint checker extended by this change.
  • PerryTS/perry#7214 — Applies related receiver rooting across allocating call-lowering operations.
  • PerryTS/perry#7226 — Modifies related GC-root analysis and static-dispatch lowering paths.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the phi false-positive fix, real-hit fixes, and reduced enforcement budget.
Description check ✅ Passed The description clearly explains the changes, related issue, validation, remaining risks, and incomplete test runs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7664-native-unrooted-residue

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.

Ralph Küpper added 5 commits August 9, 2026 19:53
…receiver hazard (#7664)

scripts/gc_root_dominance_check.py: the native/--statepoints chain treated a
phi as unconditionally transparent, so one tainted incoming edge blanket-
tainted the phi's result and a downstream use was checked against ANY CFG
path between source and use (between_blocks is deliberately path-insensitive,
sound for an ordinary register but not for a phi, whose dynamic value depends
on which edge was actually taken). All four reported unmasked hits were the
same &&/|| short-circuit join: the tainted edge never crosses a safepoint,
the OTHER edge does, and the checker reported that.

_cast_closure gains phi_all_edges: a phi joins `chain` only once every
incoming edge is independently in it. That closes the false positive and
deliberately excludes the case of a single tainted edge with its own
intervening safepoint before its predecessor's terminator; _phi_edge_hazard
covers that separately, checking each edge's own window. Two new self-test
fixtures (phi_safe_edge / phi_hazard_edge) pin both directions, each verified
against a sabotaged copy of the checker to confirm it can still fail.

lower_call/property_get/static_dispatch.rs: (Lexer as any).lex(...) reads a
module-global receiver, then held it raw across arg-bundling logic that can
allocate (a rest-param bundle always allocates; an object-literal argument
can too) before implicit_this_save/js_static_this_arm_value read the stale
copy -- the same #6969/#6986 shape #7719 just fixed in lower_call/builtin.rs,
here on the receiver. Wrapped it in RootedGroup::adopt/reread.

Re-verified against the current corpus: the checker fix eliminates exactly
the four phi false positives with nothing else changing. The static-dispatch
fix was not yet re-verified against a fresh corpus run after this rebase
(disk pressure and box load made prior corpus runs unreliable) -- see the PR
description for exactly what is and isn't confirmed.
…7664)

Re-verifying the checker fix found 9 real+false hits, not the 8 the prior
snapshot recorded -- test_gap_static_method_value_name_collision joined the
population after #7691 without the budget being re-measured. Of the 9: 4 were
the checker's own phi-edge false positives (fixed in the prior commit), 3
were unrooted:global (2 already fixed upstream by #7719, 1 fixed in the prior
commit's static_dispatch.rs change), and 2 are unrooted:capture -- real,
diagnosed, and tracked as this budget's referent rather than rushed.

Measured on the native corpus, both arms of --moving-only, stale still 0.
@proggeramlug
proggeramlug force-pushed the gc/7664-native-unrooted-residue branch from 1be0b67 to d849ff5 Compare August 9, 2026 18:16
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merging as v0.5.1420 — and I verified the one thing you flagged you couldn't

The headline finding is that the prior triage's own numbers were stale. "8 = 4 phi false positives + 4 real" was itself out of date: a fresh native corpus read 9, because a fifth real hit (test_gap_static_method_value_name_collision) joined after #7691 landed and nobody re-measured the budget. A budget that sits above reality silently absorbs the next real hazard — that is the same defect as MIN_COMPILED=90 against a 131-source corpus (#7706), one layer up.

Dropping your own builtin.rs fix in favour of #7719's — which landed mid-flight, same shape, same RootedGroup mechanism, superset of arms — was the right call rather than fighting the rebase.

I re-ran the confirmation you explicitly said you had not

You noted your "2 residual hits" came from a build containing your own now-dropped builtin.rs fix, not #7719's, and that you were relying on CI. Fair, and worth not leaving to chance since the budget change is the merge risk. On the rebased branch with a fresh --release build:

corpus (native): 131/131 sources compiled, 0 skipped, 151 .ll files
  statepoints: 9624   non-empty live bundles: 5737
gc_root_dominance_check.py --statepoints --moving-only --max-unrooted 2
  → within budget: unrooted 2 <= 2      exit 0

The budget of 2 holds against #7719's fix, not just yours.

Two things about my own run worth recording, since both are traps:

  • My first corpus attempt compiled 52/131 with 79 skipped, because I had the binary but no pinned PERRY_RUNTIME_DIR. The checker then reported 272 unrooted from a corpus two-thirds missing. gate(gc): make the root-dominance corpus floor a two-sided ratchet #7706's new ratchet caught it — the corpus exited 1 instead of quietly producing a partial answer, which is exactly what that change was for.
  • I first ran bare --statepoints (630 hits) rather than the gate's actual --statepoints --moving-only. Your workflow comment documents both numbers, which is why I noticed rather than filing a false alarm.

Scope, honestly stated

The 2 remaining unrooted:capture hits are split into #7725, and the diagnosis there is the useful part: js_closure_get_capture_bits's return value is never re-entered into the RS4GC-tracked domain or a temp root, unlike %this_closure itself. The real fix needs root_reload.rs's Facts to model a call as reloadable, not just a load — which is a genuine gap in the repair machinery, not a missing store.

The checker fix (_cast_closure's phi_all_edges) is the right shape too: chain's blanket phi propagation was reporting the untainted edge's safepoint against the tainted edge's value. A checker that over-reports trains people to raise the budget, which is how this gate would have died.

Shadow-lowering read is noted as unchecked in your test plan rather than glossed — correct, given the box was briefly disk-full and at load 30-55.

Gates 19/19.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

4356-4368: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a "still a live subject" assertion for phi_safe_edge, as the roundtrip fixture has.

The safe-edge arm asserts only that the scan reports 0. That arm passes for the right reason and for several wrong reasons: if %r2 ever stops being classified as a heap source, if the phi stops parsing, or if phi_incoming returns an empty list, the fixture still reports 0 and the arm stays green.

Lines 4340-4349 already apply the counter-measure to the roundtrip fixture: they assert the fixture's own registers still classify as expected. Apply the same shape here. Assert that phi_incoming on the join's phi returns two edges, and that one of them names %r2.

phi_hazard_edge does not need this, because a non-zero count already proves the subject is live.

💚 Proposed addition after the safe-edge arm
             ok = False
+        # ...and the safe fixture must still be a live subject: the join must
+        # parse as a two-edge phi that really does carry the tainted register,
+        # or the 0 above is a green for the wrong reason.
+        sf = parse_file(paths["phi_safe_edge"])[0]
+        sf_phis = [i for b in sf.blocks for i in sf.insns[b] if _is_phi(i)]
+        sf_edges = [e for p in sf_phis for e in phi_incoming(p)]
+        if len(sf_edges) != 2 or not any(v.strip() == "%r2" for v, _p in sf_edges):
+            print("self-test FAIL: the phi_safe_edge fixture's join must parse "
+                  "as a two-edge phi carrying %r2, or the 0 above proves "
+                  f"nothing. Parsed edges: {sf_edges!r}", file=sys.stderr)
+            ok = False
         hits = _scan_statepoints([paths["phi_hazard_edge"]], moving_only=True)
🤖 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 4356 - 4368, Strengthen the
`phi_safe_edge` self-test by asserting before or alongside the zero-hit check
that `phi_incoming` for the join phi returns exactly two incoming edges and
includes one naming `%r2`, matching the existing roundtrip fixture validation.
Keep the current zero-result assertion unchanged; do not add this assertion to
`phi_hazard_edge`.
crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs (1)

135-149: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Skip rooting when the receiver is a compile-time constant.

The _ arm produces crate::nanbox::double_literal(...), a literal NaN-boxed INT32 class id. That value is not a heap reference and cannot go stale. When has_rest is true, collects is true, so group.adopt roots the literal and group.reread reloads it. The result is a root slot and a store/load pair that protect a constant.

The Expr::ClassRef(_) arm has the same property: the comment at line 99-100 states lower_expr yields the INT32-NaN-boxed class id.

This is IR noise, not a bug. The comment at lines 142-146 states the intent to keep the common case free of rooting traffic, so the same reasoning applies here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs` around
lines 135 - 149, Update receiver rooting in the static-dispatch lowering around
group.adopt so compile-time class-reference receivers produced by the
Expr::ClassRef(_) and synthesized _ arms are never marked as collecting.
Preserve has_rest rooting for non-constant receivers, but bypass group.adopt
rooting and reread traffic for these literal INT32 NaN-box values.
🤖 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/property_get/static_dispatch.rs`:
- Around line 220-230: Update the rest-bundle construction in the static
dispatch lowering path to use the existing lower_rest_call_args_rooted rooting
pattern. Keep the rest-array accumulator and already-lowered values rooted
across lower_expr calls, reread the accumulator before each push, and append
boxed bundles afterward; include synthetic arguments as an additional array
within the same root group.

---

Nitpick comments:
In `@crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs`:
- Around line 135-149: Update receiver rooting in the static-dispatch lowering
around group.adopt so compile-time class-reference receivers produced by the
Expr::ClassRef(_) and synthesized _ arms are never marked as collecting.
Preserve has_rest rooting for non-constant receivers, but bypass group.adopt
rooting and reread traffic for these literal INT32 NaN-box values.

In `@scripts/gc_root_dominance_check.py`:
- Around line 4356-4368: Strengthen the `phi_safe_edge` self-test by asserting
before or alongside the zero-hit check that `phi_incoming` for the join phi
returns exactly two incoming edges and includes one naming `%r2`, matching the
existing roundtrip fixture validation. Keep the current zero-result assertion
unchanged; do not add this assertion to `phi_hazard_edge`.
🪄 Autofix

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: 96868c7f-a938-4df3-ab1d-606cb4f9cb39

📥 Commits

Reviewing files that changed from the base of the PR and between cbb682d and d849ff5.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • .github/workflows/gc-root-dominance.yml
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7724-native-unrooted-residue.md
  • crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs
  • scripts/gc_root_dominance_check.py

Comment on lines +220 to +230
let rest_count = args.len().saturating_sub(fixed_count);
let cap = (rest_count as u32).to_string();
let mut current = ctx.block().call(I64, "js_array_alloc", &[(I32, &cap)]);
for a in args.iter().skip(fixed_count) {
let v = lower_expr(ctx, a)?;
let blk = ctx.block();
current =
blk.call(I64, "js_array_push_f64", &[(I64, &current), (DOUBLE, &v)]);
}
let rest_box = nanbox_pointer_inline(ctx.block(), &current);
lowered.push(rest_box);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find how the spread accumulator was rooted in `#7690`, for reuse here.
set -euo pipefail

rg -n -C8 'js_array_alloc' crates/perry-codegen/src --type=rust -g '!**/tests/**' \
  | rg -n -C8 'RootedGroup|guard_store_operand|StoreOperandGuard|root_operands_begin' || true
rg -n -C10 'accumulator|spread' crates/perry-codegen/src/lower_call --type=rust | rg -n -C10 'Rooted|guard' || true

Repository: PerryTS/perry

Length of output: 17159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant static-dispatch rest-bundle code and the rooted helper if present.
fd -a 'static_dispatch.rs|mod.rs|rooting.rs' crates/perry-codegen/src | sed 's#^\./##' | sort | rg 'crates/perry-codegen/src/(lower_call/(mod\.rs|static_dispatch\.rs)|rooting\.rs)$' || true

echo '--- static_dispatch outline ---'
ast-grep outline crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs --view compact 2>/dev/null || true

echo '--- static_dispatch relevant section ---'
sed -n '160,250p' crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs | nl -ba -v160

echo '--- mod lower_rest_call_args_rooted relevant section ---'
sed -n '280,355p' crates/perry-codegen/src/lower_call/mod.rs | nl -ba -v280

echo '--- all uses of lower_rest_call_args_rooted ---'
rg -n "lower_rest_call_args_rooted" crates/perry-codegen/src -g '*.rs'

Repository: PerryTS/perry

Length of output: 327


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- static_dispatch relevant section ---'
awk 'NR>=160 && NR<=250 { printf "%5d  %s\n", NR, $0 }' crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs

echo '--- mod lower_rest_call_args_rooted relevant section ---'
awk 'NR>=280 && NR<=355 { printf "%5d  %s\n", NR, $0 }' crates/perry-codegen/src/lower_call/mod.rs

echo '--- all uses of lower_rest_call_args_rooted ---'
rg -n "lower_rest_call_args_rooted" crates/perry-codegen/src -g '*.rs' || true

Repository: PerryTS/perry

Length of output: 12461


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- static_dispatch imports/outline around lower/static property_get ---'
sed -n '1,120p' crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs

echo '--- relevant surrounding property_get static call path ---'
sed -n '50,280p' crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs

echo '--- all uses of lower_call in static_dispatch / property_get ---'
rg -n "lower_call\\(|PropertyGet|StaticMethod|property_get" crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs || true

echo '--- check for is_static_method_call related symbols in static_dispatch ---'
rg -n "is_static_method_call|lowered |lower_call|is_synth_args|has_rest|declared" crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs || true

echo '--- diff stat/name-only for context ---'
git diff --stat -- crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs || true
git diff --name-only -- crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs || true

Repository: PerryTS/perry

Length of output: 23989


Root the rest-array accumulator while building the rest bundle.

current is the js_array_alloc result held in a raw i64 SSA register. In the non-synthetic rest path, lower_expr runs inside the push loop, so user code can trigger a moving collection while current and already-pushed values remain unrooted. Use the existing lower_rest_call_args_rooted pattern: lower operands under a rooted group, build each rest array after reread, then append the boxed bundles; handle the synthetic arguments bundle as an additional array in the same root group.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs` around
lines 220 - 230, Update the rest-bundle construction in the static dispatch
lowering path to use the existing lower_rest_call_args_rooted rooting pattern.
Keep the rest-array accumulator and already-lowered values rooted across
lower_expr calls, reread the accumulator before each push, and append boxed
bundles afterward; include synthetic arguments as an additional array within the
same root group.

Source: Learnings

@proggeramlug
proggeramlug merged commit c156f8a into main Aug 9, 2026
0 of 17 checks passed
@proggeramlug
proggeramlug deleted the gc/7664-native-unrooted-residue branch August 9, 2026 18:22
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
… no-op

crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs's
three tests were red on main: js_generator_attach_prototype and
js_generator_attach_closure_prototype no longer moved their receiver under an
alloc-point copying minor, and the shipped-default witness never saw its
trigger armed.

warm_generator_intrinsics() called js_generator_attach_prototype(TAG_UNDEFINED, 0)
to pre-build the generator intrinsic tower before the timed call under test.
That never worked: js_generator_attach_prototype returns at its very first
line for any non-pointer obj, so the "warm-up" touched nothing. It went
unnoticed because GENERATOR_FUNCTION_INTRINSIC_PTR and its five siblings were
plain process-global AtomicI64s pre-#7723 - some earlier test in the same
binary had almost always already built the tower, so the real call under test
found it cached regardless of what warm_generator_intrinsics() did.

#7723 converted those six statics to per_test_global! specifically so each
test starts from a guaranteed first-touch state (crates/perry-runtime/src/gc/tests/lazy_intrinsic_towers.rs's
whole point). That is a correct, deliberate change - it took away the
accidental cross-test priming these three tests had been relying on. With
nothing pre-built, the real call now pays the dozens-of-allocations tower
build itself, inside build_generator_tower's GcSuppressScope (#7251's no-move
window for that build). That suppression window swallows the arena trigger
the test injected via arm_collection_on_next_block for the rest of the call:
no copying minor ever runs before the tower build's own scope closes, and by
then intermediate's own allocation no longer needs a new arena block, so the
trigger is never serviced. Confirmed with instrumented gc_check_trigger /
GcSuppressScope traces comparing the last-good commit against #7723: on the
last-good commit the real call's first allocation reaches gc_check_trigger
unsuppressed and services the trigger directly; on #7723 the entire ~1800-call
tower build runs suppressed first and nothing ever re-triggers afterward.

Fix warm_generator_intrinsics() to call crate::object::ensure_generator_intrinsics()
directly - the same builder lazy_intrinsic_towers.rs uses - so it does what
its name and doc comment always claimed. This does not touch the
liveness/deferral assertions those tests make; it only repairs the test's own
setup helper.

Bisected via git checkout of each of today's three merges in an isolated
worktree: c907953 (pre-#7721) passes; ca8c0d6 (#7721, moving-loop poll
default flip) passes; cbb682d (#7723, no-move window + per_test_global
towers) is the first commit where all three fail. #7724 is uninvolved.
proggeramlug added a commit that referenced this pull request Aug 9, 2026
… no-op (#7731)

* fix(gc): warm_generator_intrinsics must call the tower builder, not a no-op

crates/perry-runtime/src/gc/tests/runtime_roots/generator_attach_prototype.rs's
three tests were red on main: js_generator_attach_prototype and
js_generator_attach_closure_prototype no longer moved their receiver under an
alloc-point copying minor, and the shipped-default witness never saw its
trigger armed.

warm_generator_intrinsics() called js_generator_attach_prototype(TAG_UNDEFINED, 0)
to pre-build the generator intrinsic tower before the timed call under test.
That never worked: js_generator_attach_prototype returns at its very first
line for any non-pointer obj, so the "warm-up" touched nothing. It went
unnoticed because GENERATOR_FUNCTION_INTRINSIC_PTR and its five siblings were
plain process-global AtomicI64s pre-#7723 - some earlier test in the same
binary had almost always already built the tower, so the real call under test
found it cached regardless of what warm_generator_intrinsics() did.

#7723 converted those six statics to per_test_global! specifically so each
test starts from a guaranteed first-touch state (crates/perry-runtime/src/gc/tests/lazy_intrinsic_towers.rs's
whole point). That is a correct, deliberate change - it took away the
accidental cross-test priming these three tests had been relying on. With
nothing pre-built, the real call now pays the dozens-of-allocations tower
build itself, inside build_generator_tower's GcSuppressScope (#7251's no-move
window for that build). That suppression window swallows the arena trigger
the test injected via arm_collection_on_next_block for the rest of the call:
no copying minor ever runs before the tower build's own scope closes, and by
then intermediate's own allocation no longer needs a new arena block, so the
trigger is never serviced. Confirmed with instrumented gc_check_trigger /
GcSuppressScope traces comparing the last-good commit against #7723: on the
last-good commit the real call's first allocation reaches gc_check_trigger
unsuppressed and services the trigger directly; on #7723 the entire ~1800-call
tower build runs suppressed first and nothing ever re-triggers afterward.

Fix warm_generator_intrinsics() to call crate::object::ensure_generator_intrinsics()
directly - the same builder lazy_intrinsic_towers.rs uses - so it does what
its name and doc comment always claimed. This does not touch the
liveness/deferral assertions those tests make; it only repairs the test's own
setup helper.

Bisected via git checkout of each of today's three merges in an isolated
worktree: c907953 (pre-#7721) passes; ca8c0d6 (#7721, moving-loop poll
default flip) passes; cbb682d (#7723, no-move window + per_test_global
towers) is the first commit where all three fail. #7724 is uninvolved.

* changelog: add fragment for #7731 (generator-attach-pacing)

* chore: bump version to 0.5.1422

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

---------

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