Skip to content

gc(codegen): root the spread argument-bundle accumulator (#7664) - #7696

Merged
proggeramlug merged 3 commits into
mainfrom
gc/7664-spread-accumulator-rooting
Aug 9, 2026
Merged

gc(codegen): root the spread argument-bundle accumulator (#7664)#7696
proggeramlug merged 3 commits into
mainfrom
gc/7664-spread-accumulator-rooting

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes part of #7664 and lowers gc-root-dominance-statepoints's
--max-unrooted from 11 to 8.

The window

Six arms of expr/call_spread.rs bundle every argument — regular and spread,
in source order — into one JS array before dispatching (console.* spread, the
recv.m(...) and recv[k](...) method-apply arms, the namespace REST export,
and the closure-callee path's interleaved and multi-spread arms). All six wrote
the same loop and all six held the half-built array in a bare i64 SSA register
across it:

%acc  = call i64 @js_array_alloc(i32 0)
%box  = call double @perry_fn_…(…)                      ; arbitrary user code
%part = call i64 @js_array_like_to_array(double %box)   ; ALLOCATES
%acc2 = call i64 @js_array_concat(i64 %acc, i64 %part)  ; %acc is stale

--statepoints --moving-only reports it as unrooted:alloc — nothing in the
register's cast chain appears in the js_array_like_to_array safepoint's live
bundle, so an evacuating minor there neither marks nor rewrites the array and
js_array_concat reads from-space. It is #7453's shape (a fresh heap value in a
raw register across an allocating helper) in the spread lowering rather than the
URL one, reachable from a.splice(1, 0, ...src).

Why one helper rather than six operand_protection answers

The window is never empty here. An Expr::CallSpread has at least one
spread source by construction, and js_array_like_to_array allocates
unconditionally — so f(...[1, 2]), every operand an inert literal, is already
the bug, and an operands-only "can anything here collect?" predicate answers
false for it. bundle_args_rooted disjoins the spread-present test with
rooting::any_operand_may_collect for exactly that reason, and
an_all_inert_spread_bundle_still_roots_its_accumulator pins it.

The fold itself is RootedAcc::advance, so the accumulator never exists as a
register the loop holds across an emission: the re-read is fused to the
js_array_concat / js_array_push_f64 that consumes it, below the allocating
conversion. Ordering is the fix — a re-read above js_array_like_to_array
would root an already-stale pointer, which is #7192's shape.

Measured

Native corpus (150 modules, 2538 functions, 32689 statepoints, 19593 live
bundles), the same binary in both arms:

unrooted stale
before 11 0
after 8 0

The three that disappear are exactly the three unrooted:alloc hits in
test_gap_array_splice_spread::main; the other eight are byte-identical
(4 unmasked, 2 global, 2 capture). No hit was added.

Correctness: every gap test containing a spread call — 71 sources, selected
by \w\(\s*\.\.\. / ,\s*\.\.\. rather than by hand — is byte-identical to
the pinned Node 26.5.1 oracle
. 0 compile failures, 0 node_fail.

Gates: all 24 lint-job commands green (each command's own exit status
checked, and the extraction asserts it found 24). cargo fmt --all --check
clean.

The tests, and the arm that proves they can fail

expr/call_spread_rooting_tests.rs, three cases, ordering only — never slot
counts
. That is slice 8's lesson restated because this file would otherwise
have fallen into it: on the default build reserve_shadow_slot returns a
stack-map index and the pooled-alloca lowering emits a plain store/load, so
no js_gc_temp_root_* call is emitted at all and a temp_root_calls(ir) > 0
assertion reads zero and passes vacuously. Definition-line ordering is visible in
all three lowerings.

The helper walks every js_array_concat, not the first — a bundle emits one
per spread source and require_call_line would leave a partially-fixed loop
green.

Sabotage arm run and recorded: forcing protect = false (which reproduces the
pre-fix IR exactly) takes the suite to 0 passed; 3 failed — i.e. the tests ran
and failed on the assertion, not on a compile error. Reverted, and the file
touched afterwards so the restored mtime is not older than the build.

★ Four of the remaining eight are checker false positives

The workflow comment previously described them as "4 unmasked, all PHI-MEDIATED
… the reload has to go in the PREDECESSOR, on the edge, which is a different
insertion model". That is solving a problem that is not there, and this PR
corrects the comment rather than the code.

"phi" is in TRANSPARENT_OPS (gc_root_dominance_check.py:1007), so taint
flows from a phi operand to the phi result and the use is located at the
join. But a phi operand is used on its own incoming edge. All four hits have
the identical shape — an && join — e.g. readCtx:

entry.0:   %r2 = <unmask of the receiver>
           br i1 %r4, label %logical.then.1, label %logical.merge.2
logical.merge.2:
           %r87 = phi double [ %r2, %entry.0 ], [ %r86, %pget.recv_merge.7 ]
           ret double %r87

Every safepoint is on the %logical.then.1 path, where the phi selects %r86.
On the edge that carries %r2 nothing collects between its definition and the
join. Verified register-by-register on all four (readCtx, __closure_5,
Readable, __obj_method_toLocaleString_3).

So the real residual is 4, and the comment now says so and says what each is.
It also records why the capture population is not reloadable the way a
string-handle global is: the recipe would have to re-derive the closure pointer
from %this_closure, an i64 parameter RS4GC does not relocate, so the
re-read would address the pre-move closure.

An edge-sensitive phi rule is deliberately not in this PR: it lowers a
reported count, so it must arrive with a sabotage arm proving it still reports a
phi operand that is live across a safepoint on its own edge, and that belongs
with the checker change rather than riding along on a codegen fix.

Not fixed here, deliberately

Two arms lower recv_box (and key_box) before the bundle and use them
after — a second, distinct window on an operand rather than the accumulator.
Closing it needs the receiver and the array in one RootedGroup scope so the
release post-dominates the dispatch; that is #7640's population and a different
acceptance test. Both sites now carry a comment saying so rather than being
half-fixed silently. The same applies to cb_box and the regular-argument stack
buffer on the closure-callee path (#7210 group 2).

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when handling spread arguments across calls, including interleaved and multiple spread values.
    • Prevented spread argument accumulators from becoming invalid during memory allocations.
    • Preserved existing argument ordering and call behavior.
  • Tests

    • Added coverage validating safe spread argument handling across several call patterns.
  • Documentation

    • Documented remaining native statepoint checker findings and clarified known false positives.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Call-spread lowering now uses a shared rooted accumulator helper across six lowering paths. LLVM IR tests verify accumulator ordering across allocating operations. The native statepoint gate documents remaining findings and lowers its unrooted threshold from 11 to 8.

Changes

Spread argument rooting

Layer / File(s) Summary
Rooted spread bundling
crates/perry-codegen/src/expr/call_spread.rs
bundle_args_rooted collects regular and spread arguments in source order, roots the accumulator across allocations, and supports six call-spread paths.
Rooting regression coverage
crates/perry-codegen/src/expr/call_spread_rooting_tests.rs, crates/perry-codegen/src/expr/mod.rs
Tests compile synthetic HIR to LLVM IR and verify accumulator definitions follow allocating spread conversions for interleaved, multi-spread, and inert bundles.
Statepoint gate update
.github/workflows/gc-root-dominance.yml, changelog.d/7696-spread-accumulator-rooting.md
The native checker documentation records resolved, false-positive, global, and capture findings. The maximum allowed unrooted count changes from 11 to 8.

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

Sequence Diagram(s)

sequenceDiagram
  participant CallSpreadLowering
  participant bundle_args_rooted
  participant LLVMCodegen
  CallSpreadLowering->>bundle_args_rooted: collect regular and spread arguments
  bundle_args_rooted->>LLVMCodegen: convert spread source and concatenate rooted accumulator
  LLVMCodegen-->>CallSpreadLowering: source-ordered argument array
  CallSpreadLowering->>LLVMCodegen: dispatch lowered call
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6972 — Adds related temporary-rooting infrastructure for spread-argument accumulators.
  • PerryTS/perry#7192 — Extends GC root dominance work for spread-argument accumulators.
  • PerryTS/perry#7299 — Modifies GC rooting for spread argument accumulators in code generation.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main code-generation change: rooting the spread argument-bundle accumulator.
Description check ✅ Passed The description thoroughly covers the change, rationale, tests, measured results, related issues, and intentionally deferred work.
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-spread-accumulator-rooting

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
crates/perry-codegen/src/expr/call_spread_rooting_tests.rs (1)

144-204: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the non-closure arms.

The three tests exercise two of the six lowering arms: the interleaved closure arm and the multi-spread closure arm. The console arm, the PropertyGet method-apply arm, the IndexGet method-apply arm, and the namespace rest-function arm are not covered.

All six arms call bundle_args_rooted, so the accumulator ordering is structurally identical and the current coverage does validate the helper. A per-arm test would instead catch an arm that stops using the helper. Add one case for console.log(...a, ...b) and one for recv.m(...a, ...b) if you want that guard.

🤖 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/call_spread_rooting_tests.rs` around lines 144
- 204, Add coverage for the non-closure lowering arms in the call-spread rooting
tests: add one case exercising console.log(...a, ...b) and one exercising
recv.m(...a, ...b), using assert_every_fold_rereads_the_accumulator to verify
bundle_args_rooted remains used. Preserve the existing test style and focus on
the console and PropertyGet method-apply paths; the remaining arms do not
require separate cases.
crates/perry-codegen/src/expr/call_spread.rs (1)

461-468: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Escaped accumulator handles rely on an untested emission-order invariant.

Both closure arms return the accumulator register out of the bundle_args_rooted rooted scope. Safety depends on js_closure_call_apply_with_spread at Line 519 being the next emission. I traced both paths and the invariant holds today. It is enforced only by the code comments, so a later insertion into either gap would create a stale handle with no failing test.

  • crates/perry-codegen/src/expr/call_spread.rs#L461-L468: the interleaved arm returns acc_handle; add an IR assertion that no allocating runtime call appears between the last definition of this handle and the js_closure_call_apply_with_spread that reads it.
  • crates/perry-codegen/src/expr/call_spread.rs#L511-L514: the multi-spread arm returns spread_handle through the same pattern; cover it with the same assertion, since regs_ptr and regs_len are emitted above at Lines 472-493 and the ordering could change.
🤖 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/call_spread.rs` around lines 461 - 468, Add IR
assertions in both accumulator escape paths of call-spread generation: the
interleaved arm at crates/perry-codegen/src/expr/call_spread.rs:461-468 and the
multi-spread arm at crates/perry-codegen/src/expr/call_spread.rs:511-514. Assert
that no allocating runtime call is emitted between the final definition of
acc_handle or spread_handle and the corresponding
js_closure_call_apply_with_spread consumption, so later emissions cannot
invalidate the rooted handle.
🤖 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 `@changelog.d/7696-spread-accumulator-rooting.md`:
- Line 25: Update the compound modifier in the changelog text around
bundle_args_rooted and rooting::with_rooted_accumulator from “two fold” to
“two-fold,” without changing the surrounding wording.

---

Nitpick comments:
In `@crates/perry-codegen/src/expr/call_spread_rooting_tests.rs`:
- Around line 144-204: Add coverage for the non-closure lowering arms in the
call-spread rooting tests: add one case exercising console.log(...a, ...b) and
one exercising recv.m(...a, ...b), using
assert_every_fold_rereads_the_accumulator to verify bundle_args_rooted remains
used. Preserve the existing test style and focus on the console and PropertyGet
method-apply paths; the remaining arms do not require separate cases.

In `@crates/perry-codegen/src/expr/call_spread.rs`:
- Around line 461-468: Add IR assertions in both accumulator escape paths of
call-spread generation: the interleaved arm at
crates/perry-codegen/src/expr/call_spread.rs:461-468 and the multi-spread arm at
crates/perry-codegen/src/expr/call_spread.rs:511-514. Assert that no allocating
runtime call is emitted between the final definition of acc_handle or
spread_handle and the corresponding js_closure_call_apply_with_spread
consumption, so later emissions cannot invalidate the rooted handle.
🪄 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: 5e452a1b-7827-4d40-bf55-41c489623aba

📥 Commits

Reviewing files that changed from the base of the PR and between d1aa968 and ada4046.

📒 Files selected for processing (5)
  • .github/workflows/gc-root-dominance.yml
  • changelog.d/7696-spread-accumulator-rooting.md
  • crates/perry-codegen/src/expr/call_spread.rs
  • crates/perry-codegen/src/expr/call_spread_rooting_tests.rs
  • crates/perry-codegen/src/expr/mod.rs

URL one, and it is reachable from `a.splice(1, 0, ...src)`.

**The window is never empty here**, which is why the six sites share one helper
(`bundle_args_rooted`, a `rooting::with_rooted_accumulator` with the two fold

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Hyphenate “two-fold.”

Line 25 uses “two fold” as a compound modifier. Change it to “two-fold.”

Proposed wording
-(`bundle_args_rooted`, a `rooting::with_rooted_accumulator` with the two fold steps this lowering needs) rather than each asking `operand_protection`
+(`bundle_args_rooted`, a `rooting::with_rooted_accumulator` with the two-fold steps this lowering needs) rather than each asking `operand_protection`
📝 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
(`bundle_args_rooted`, a `rooting::with_rooted_accumulator` with the two fold
(`bundle_args_rooted`, a `rooting::with_rooted_accumulator` with the two-fold
steps this lowering needs) rather than each asking `operand_protection`
🧰 Tools
🪛 LanguageTool

[grammar] ~25-~25: Use a hyphen to join words.
Context: ...g::with_rooted_accumulator` with the two fold steps this lowering needs) rather t...

(QB_NEW_EN_HYPHEN)

🤖 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 `@changelog.d/7696-spread-accumulator-rooting.md` at line 25, Update the
compound modifier in the changelog text around bundle_args_rooted and
rooting::with_rooted_accumulator from “two fold” to “two-fold,” without changing
the surrounding wording.

Source: Linters/SAST tools

Ralph Küpper added 3 commits August 9, 2026 13:35
Six arms of `expr/call_spread.rs` bundle every argument -- regular and
spread, in source order -- into one JS array before dispatching, and all
six held the half-built array in a bare `i64` SSA register across the
loop that fills it:

  %acc  = call i64 @js_array_alloc(i32 0)
  %box  = call double @perry_fn_...(...)                  ; user code
  %part = call i64 @js_array_like_to_array(double %box)   ; ALLOCATES
  %acc2 = call i64 @js_array_concat(i64 %acc, i64 %part)  ; %acc is stale

`--statepoints --moving-only` reports that as `unrooted:alloc`: nothing in
the register's cast chain appears in the `js_array_like_to_array`
safepoint's live bundle, so an evacuating minor there neither marks nor
rewrites the array and `js_array_concat` reads from-space.

The window is never empty, which is why the six sites share one helper
rather than each asking `operand_protection`: an `Expr::CallSpread` has at
least one spread source by construction and `js_array_like_to_array`
allocates unconditionally, so `f(...[1, 2])` -- every operand an inert
literal -- is already the bug. An operands-only predicate answers `false`
for it.

Measured on the native corpus (150 modules, 32689 statepoints), same
binary in both arms: 11 -> 8 unrooted hazards, the other eight
byte-identical, `stale` still 0. The budget in
`gc-root-dominance-statepoints` drops to 8 in the same commit.

71/71 gap tests containing a spread call are byte-identical to the pinned
Node 26.5.1 oracle.

The workflow comment also records what the remaining eight are, because
four of them are NOT what it previously said: the `unmasked` population is
a checker false positive. `"phi"` is in `TRANSPARENT_OPS`, so taint flows
from a phi operand to the phi result and the use is located at the join --
but a phi operand is used on its own incoming edge, and in all four cases
the safepoints are on the OTHER edge. Verified register-by-register.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1396. Budget 11 → 8.

Sabotage verified independently: forcing protect = false reddens all three tests (error[ 0, Running unittests present), green on restore. That is the right assertion to have — the fix is a computed predicate, so a test that passes with it hard-wired off would be pinning nothing.

The argument for one helper over six operand_protection answers is the strongest part, and an_all_inert_spread_bundle_still_roots_its_accumulator is exactly the test that proves it: CallSpread always has ≥1 spread source and js_array_like_to_array allocates unconditionally, so f(...[1, 2]) — every operand inert — is already the bug, and an operands-only predicate answers false. A per-operand verdict would have been correct-looking and wrong.

150 modules / 32,689 statepoints, same binary both arms: exactly the three unrooted:alloc hits gone, the other eight byte-identical, stale still 0. 71/71 spread-containing gap tests byte-identical to node.

Your correction to my brief is the more valuable half

The remaining 4 are not phi-mediated hazards needing an edge-insertion model — they are checker false positives.

I inherited "phi-mediated, needs the reload in the predecessor on the edge" from the issue and passed it on as fact. "phi" is in TRANSPARENT_OPS, so taint flows operand→result and the use is located at the join — but a phi operand is used on its own incoming edge, and every safepoint in those four is on the other edge. Verified register by register on all four.

So the real residual is 4, not 8, and the workflow comment was wrong too. Declining to ship the edge-sensitive rule in this PR is right for the reason you give — it lowers a reported count, so it must arrive with its own sabotage arm. A checker fix that quietly makes numbers smaller is the thing this whole campaign exists to prevent.

The two capture hazards are correctly characterised as not reloadable: the recipe would re-derive from %this_closure, an i64 parameter RS4GC does not relocate, so the re-read yields the pre-move pointer. That needs the callee's closure pointer to be a tracked root first — a different change.

Gates: 24/24 lint, fmt clean, perry-codegen --lib 781, perry-runtime --lib 1938, native_root_coverage 14/14.

The rest of your report — #7341's triage (55 → 14 measured, bucket (b) empty), #7164's real defect (a populated pointer mask over a zero-length traced payload, because layout_note_slot flips to SIDE_MASK without widening field_count), #7528's acceptance criterion having gone vacuous under #7687, and #7210's per-site re-adjudication — I am picking up separately. #7164 in particular is a one-line fix for a real memory-safety hole and should not sit.

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