Skip to content

fix(codegen): the lowering-window cluster — push order, member updates, ctor args, computed keys (#7634, #7628, #6998, #6986, #7640) - #7699

Merged
proggeramlug merged 7 commits into
mainfrom
fix/7634-push-receiver-order
Aug 9, 2026
Merged

fix(codegen): the lowering-window cluster — push order, member updates, ctor args, computed keys (#7634, #7628, #6998, #6986, #7640)#7699
proggeramlug merged 7 commits into
mainfrom
fix/7634-push-receiver-order

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Closes #7634, #7628, #6998. Advances #6986 (the three lower_new branches it names; builtin.rs's ~22 arms
stay open, with the inventory on the issue) and #7640 (sections B and D; A, C and E stay open).

Also files #7700 — a behavioural gap found while verifying #6998.

The codegen lowering-window cluster: five issues, all "a lowering holds a GC value across something that can collect, or evaluates operands in the wrong order". One commit per issue, plus one for a bug I introduced and caught.

#7634arr.push(f()) evaluated the receiver AFTER the argument

Spec bug first. ES2024 evaluates the MemberExpression arr.push to a Reference before the argument list, so an argument that rebinds the receiver cannot redirect the push. Perry pushed onto whatever the binding named afterwards.

                             node 26.5.1   perry (before)
a.push(f())   f: a = [9]     [9]           [9,2]
b.push(...g()) g: b = [9]    [9]           [9,2,3]

The fix is gated on the divergence being observable, and that is the substance of it. A blanket reorder puts every push into a rooting window — rows.push({...}), out.push(f(x)) — on what #7511 measured as the hottest store family in the compiler. push_receiver_is_rebindable is the as-if test: the two orders name the same array unless the argument assigns the receiver's id itself, or the binding is boxed (collect_boxed_vars' rule is "captured AND mutated", so a captured-but-never-assigned array stays on the fast path) or a module global and the argument can reach a collection point.

Evidence for the no-cost claim: --trace llvm over a 1000-iteration out.push(mk(i)) loop plus a captured rows.push(i * 2) arrow contains zero call i32 @js_gc_temp_root_push and zero spec-order blocks.

One thing the issue did not anticipate: the reorder alone is not sufficient. Every fast tier publishes the reallocated array head back into the binding unconditionally, and once the argument may have rebound it that store lands on the wrong array (a.push(f()) would overwrite [9] with the grown [1,2]). The spec-ordered arm therefore skips the inline tiers and guards its write-back on the binding still naming the array that was pushed onto; when it does not, the store is skipped and aliases stay valid through the forwarding pointer js_array_push_f64 installs (#233).

test-files/test_gap_7634_push_receiver_order.ts matches node byte-for-byte, including push's own result and the alias that keeps the array the push landed on.

#7628a[i]++ / o.f++

Both arms move to RootedGroup, which re-reads at any number of caller-chosen points. The issue asked for that combinator; slice 6 had already built it, so no new primitive arrives with this caller.

The operand half the issue filed is not a live bug, and the sabotage arm is how that was established rather than argued. Collapsing the per-use re-reads back to one — and, for PropertyUpdate, removing the receiver's root outright — leaves the emitted IR unchanged in the relevant respect. root_reload (#7280) rematerialises the slot load at every use a collection point can reach, including through the ptrtoint + and POINTER_MASK handle derivation that #7280's own taxonomy lists as case (a), the class it cannot repair:

  %r49.rs4p = load ptr addrspace(1), ptr %r29     ; inserted by root_reload
  %r49      = ptrtoint ptr addrspace(1) %r49.rs4p to i64
  %r50      = and i64 %r49, 281474976710655
  call void @js_object_set_field_by_name(i64 %r50, i64 %r53, double %r46)

That taxonomy entry is about a raw handle a helper returns, not one masked out of a NaN-boxed value the pass has spilled. The per-use re-reads are kept — free, and they drop the dependence on a pass with a documented side condition — but they are documented as belt-and-braces and their two tests are named as pipeline assertions rather than lowering assertions.

The repair is the result. For a BigInt element js_to_numeric / js_numeric_step return a heap BigIntHeader, and the value the expression yields is live across a user setter as a bare call result with no slot for root_reload to reload from — the taxonomy's case (d). RootedGroup::adopt_emitted closes it, gated on is_provably_not_bigint so a typed-array ta[i]++ keeps the IR it had. Sabotaging that gate turns the test red; the typed-array arm is the measured counterfactual.

#6998pointer_locals typed every Uint8ArrayGet as a Number

Reachability, which the issue explicitly left open, is now established on emitted HIR rather than argued — const it = u8[Symbol.iterator] gives Let { ty: Any, init: Uint8ArrayGet { index: SymbolFor(…) } }. The arm now answers Number only for a structurally numeric key and None otherwise; the byte-read arm #6996 paid to keep free is unchanged and pinned.

Found while verifying it, and worth more than the fix: five other collectors and type_analysis/numeric.rs make the same unconditional assumption and force the i32 lowering, so a non-numeric key on a typed-array-typed local reads a byte instead of the property (typeof u8[Symbol.iterator] is number; u8[k] with k = "byteLength" is 0). No heap value reaches such a local today, so this is a latent-soundness fix that becomes load-bearing when that is repaired. Filed separately rather than folded in — those collectors decide the buf[i] i32 fast path.

#6986 — constructor arguments on lower_new's non-class branches

Three branches early-return before the main class loop ever adopts into the enclosing scope, so the scope was open around them and empty: new Readline(…), new <importedFn>(…), new Function(…). All three now adopt as each operand is produced — interleaved, never appended — and re-read at the call. lower_js_args_array is no rescue and is untouched: a plain alloca_entry_array pack with no js_shadow_slot_bind.

Not closed: builtin.rs's ~22 arms. lower_builtin_new takes no rooting context, so they are unreachable from a new.rs-level fix; the inventory is on the issue.

#7640 — sections D and B

Section D, all six sites. unbox_str_handle calls js_get_string_pointer_unified, which materialises an SSO value into a fresh heap StringHeader. Six sites took the receiver's raw untagged pointer above it. Four are pure statement swaps at zero cost; two are cross-block and re-derive the handle in the string sub-block.

Section B, seven arms of index_get.rs that lowered a receiver, then an unconstrained index (o[f()]), then used the receiver, with no rooting decision at all. Each is now one with_operands_rooted group over [object, index]; where the index provably cannot collect, operand_protection answers Reuse and the group emits nothing.

Sections A, C and E stay open.

A bug this PR introduced and caught

The cross-block section-D repair first shadowed the entry block's obj_handle, which the numeric sibling block still uses — a definition in the string block does not dominate it. The LLVM verifier rejected the module, and the root-dominance corpus recorded that as ten silently "skipped" sources, including test_gap_gc_index_get_receiver_rooting. Distinct names; corpus back to 130/130, 0 skipped. Recorded here because "skipped" reading as neutral in a corpus generator is the shape of #7024.

Verification

  • node 26.5.1 (.node-version), byte-for-byte, for all three new gap files.
  • Behavioural smoke, 70 test-files/ sources matching index|array|buffer|uint8|string|push|typed|symbol|proxy|getter: 69 pass, 0 skip, 1 diff — test_compat_buffers_typed's toSorted/toReversed, A/B'd against a compiler built from d1aa968e9 and byte-identical there, so pre-existing.
  • cargo test -p perry-codegen --lib --no-fail-fast: 786 pass. cargo test -p perry-runtime --lib: 1935 pass. cargo check --all-targets: clean.
  • The 14 native_root_coverage tests: pass.
  • All 24 lint commands extracted from test.yml, plus cargo fmt --all -- --check: pass.
  • Root dominance, shadow arm (--moving-only --seeded-violations 40): 40 planted, 40 caught, 0 missed; corpus 130/130.
  • Root dominance, native arm (--statepoints --moving-only --max-unrooted 11): see the comment below for the result.

Sabotage arms were run for every acceptance test that claims a repair, and the two that could NOT be made to fail are relabelled rather than left looking like gates.

Summary by CodeRabbit

  • Bug Fixes

    • Corrected evaluation order for Array.prototype.push when arguments can rebind the receiver.
    • Improved reliability of computed property access, typed-array indexing, constructors, and increment/decrement expressions.
    • Preserved correct BigInt, numeric, symbol-keyed, and typed-array behavior across affected operations.
  • Tests

    • Added regression coverage for receiver rebinding, computed keys, indexed updates, constructor arguments, and typed-array access.
  • Documentation

    • Added changelog entries describing the fixes and their supported scenarios.

@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: 73461e6a-09eb-45ed-bdde-b33a50613632

📥 Commits

Reviewing files that changed from the base of the PR and between a1e1340 and 8f88c10.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml

📝 Walkthrough

Walkthrough

The PR adds rooting and re-read logic for member updates, computed access, constructors, and rebindable array pushes. It also corrects Uint8ArrayGet pointer classification and adds compiler, IR, runtime, and changelog coverage.

Changes

Member update rooting

Layer / File(s) Summary
Member update implementation and wiring
crates/perry-codegen/src/expr/member_update.rs, crates/perry-codegen/src/expr/instance_misc1.rs, crates/perry-codegen/src/expr/mod.rs
Property and index updates use rooted read-modify-write flows. BigInt results remain rooted across setter calls.
Member update validation
crates/perry-codegen/src/expr/issue7628_rooting_tests.rs, crates/perry-codegen/src/expr/slice8_rooting_tests.rs, test-files/test_gap_7628_index_update_rooted.ts, changelog.d/7699-member-update-rooting.md
IR and runtime tests verify operand reloads, result rooting, numeric and BigInt behavior, accessor receivers, computed keys, and prefix/postfix results.

Push receiver evaluation

Layer / File(s) Summary
Rooted push lowering and validation
crates/perry-codegen/src/expr/array_push.rs, test-files/test_gap_7634_push_receiver_order.ts, changelog.d/7699-push-receiver-order.md
Rebindable regular and spread pushes evaluate and root the receiver before arguments. Shared write-back logic guards storage updates after rebinding.

Computed access and constructor rooting

Layer / File(s) Summary
Computed index rooting and handle re-derivation
crates/perry-codegen/src/expr/index_get.rs, crates/perry-codegen/src/expr/index_set.rs, test-files/test_gap_7640_computed_key_windows.ts, changelog.d/7699-new-and-computed-key-windows.md
Index paths root receivers and keys across allocating operations. String keys are materialized before raw receiver handles are derived or refreshed.
Constructor argument rooting
crates/perry-codegen/src/lower_call/new.rs, changelog.d/7699-new-and-computed-key-windows.md
Readline, imported-function, and dynamic Function constructors adopt and reload arguments before runtime calls.

Typed-array pointer classification

Layer / File(s) Summary
Uint8ArrayGet classification and tests
crates/perry-codegen/src/collectors/pointer_locals.rs, changelog.d/7699-uint8array-get-pointer-classification.md, Cargo.toml, CLAUDE.md
Uint8ArrayGet is numeric only for structurally numeric indices. Unknown and symbol-capable indices remain pointer-conservative. The workspace version changes to 0.5.1397.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

Suggested labels: bug, parity

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Member updates, typed-array typing, constructor arguments, and computed-key rooting are not covered by the only provided linked issue, #7634. Link the corresponding issues or split unrelated fixes into separate pull requests.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the codegen fixes for push order, member updates, constructor arguments, and computed keys.
Description check ✅ Passed The description provides detailed change, issue, and verification information, although it does not use the template headings or checklist.
Linked Issues check ✅ Passed The implementation satisfies #7634 by fixing receiver evaluation order, preserving the fast path, guarding write-back, and adding ordinary and spread push tests.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7634-push-receiver-order

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.

proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Root-dominance, both arms, on the final tree

Corpus generated with this branch's compiler (PERRY_BIN + PERRY_RUNTIME_DIR pinned to the same perry-dev build; PERRY_GC_MOVING_LOOP_POLLS left to the script, which is the whole reason the corpus lives in a script).

Shadow--moving-only --min-files 90 --min-binds 1500 --min-funcs 1200 --allowlist scripts/gc_root_dominance_allowlist.json --seeded-violations 40

corpus (shadow): 130/130 sources compiled, 0 skipped, 150 .ll files
=== seeded violations: 40 planted, 40 caught, 0 MISSED
DOM_SHADOW_EXIT=0

Native--statepoints --moving-only --min-statepoints 15000 --min-live-bundles 8000 --min-relocates 20000 --max-unrooted 11 --max-stale 0 --seeded-violations 40

corpus (native): 130/130 sources compiled, 0 skipped, 150 .ll files
  statepoints: 32715   non-empty live bundles: 19658
=== statepoint hazards: 11  (unrooted: 11, stale: 0)
       4  unrooted/unmasked
       3  unrooted/alloc
       2  unrooted/global
       2  unrooted/capture
within budget: unrooted 11 <= 11
within budget: stale 0 <= 0
=== seeded violations: 40 planted, 40 caught, 0 MISSED
DOM_NATIVE_EXIT=0

Exactly at the #7679 ratchet — no new hits, and none removed either, so the budget is unchanged.

The number that mattered here was skipped, not unrooted

The first run of this corpus reported 120/130 sources compiled, 10 skipped and still exited 0, because MIN_COMPILED is 90. Those ten were not churn: nine of them plus test_gap_gc_index_get_receiver_rooting had stopped compiling under this branch, on an LLVM verifier error this PR introduced (Instruction does not dominate all uses — the cross-block section-D repair shadowed a handle the numeric sibling block still used). A corpus generator that reports a shrink as a neutral word, under a floor set generously enough to absorb it, is the #7024 shape: the gate ran, its subject did not. Fixed in 3698b264e; both arms above are 130/130.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-codegen/src/lower_call/new.rs (1)

282-298: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use lower_constructor_arg for all Readline operands.

group.lower and lower_expr do not clear ctx.discard_expr_value. If new Readline(...) is in a discarded expression statement, a typed-array-store operand can produce 0 instead of its JavaScript value. This bypasses the constructor-argument behavior that adopt_constructor_args enforces.

Proposed fix
                     Some(first) => {
                         let collects = rooting::any_operand_may_collect(ctx, args[1..].iter());
-                        Some(group.lower(ctx, first, collects)?)
+                        let value = lower_constructor_arg(ctx, first)?;
+                        Some(group.adopt(ctx, first, &value, collects))
                     }
@@
                     Some(second) => {
                         let collects = rooting::any_operand_may_collect(ctx, args[2..].iter());
-                        Some(group.lower(ctx, second, collects)?)
+                        let value = lower_constructor_arg(ctx, second)?;
+                        Some(group.adopt(ctx, second, &value, collects))
                     }
@@
                 for extra in args.iter().skip(2) {
-                    let _ = lower_expr(ctx, extra)?;
+                    let _ = lower_constructor_arg(ctx, extra)?;
                 }
🤖 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/new.rs` around lines 282 - 298, The
Readline constructor argument lowering must use lower_constructor_arg for
output, options, and every extra operand instead of group.lower and lower_expr.
Update the argument handling around the Readline lowering branch, preserving the
existing collects calculations and optional-argument behavior while ensuring all
operands follow adopt_constructor_args semantics.
🤖 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/expr/issue7628_rooting_tests.rs`:
- Around line 55-62: Update the stale module references in the doc comments
around the test description and line 178, replacing expr/instance_misc1.rs with
expr/member_update.rs. Keep the surrounding explanation unchanged.

---

Outside diff comments:
In `@crates/perry-codegen/src/lower_call/new.rs`:
- Around line 282-298: The Readline constructor argument lowering must use
lower_constructor_arg for output, options, and every extra operand instead of
group.lower and lower_expr. Update the argument handling around the Readline
lowering branch, preserving the existing collects calculations and
optional-argument behavior while ensuring all operands follow
adopt_constructor_args semantics.
🪄 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: 0c961dac-8141-4dbd-82f3-c8fe4c19757a

📥 Commits

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

📒 Files selected for processing (17)
  • changelog.d/7699-member-update-rooting.md
  • changelog.d/7699-new-and-computed-key-windows.md
  • changelog.d/7699-push-receiver-order.md
  • changelog.d/7699-uint8array-get-pointer-classification.md
  • crates/perry-codegen/src/collectors/pointer_locals.rs
  • crates/perry-codegen/src/expr/array_push.rs
  • crates/perry-codegen/src/expr/index_get.rs
  • crates/perry-codegen/src/expr/index_set.rs
  • crates/perry-codegen/src/expr/instance_misc1.rs
  • crates/perry-codegen/src/expr/issue7628_rooting_tests.rs
  • crates/perry-codegen/src/expr/member_update.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/slice8_rooting_tests.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • test-files/test_gap_7628_index_update_rooted.ts
  • test-files/test_gap_7634_push_receiver_order.ts
  • test-files/test_gap_7640_computed_key_windows.ts

Comment on lines +55 to +62
//! # The remaining test is a PIPELINE assertion, and says so
//!
//! [`the_emitted_ir_rereads_both_operands_below_the_read`] holds because of
//! `root_reload`, not because of this file's source form, and it cannot fail on
//! a change to `expr/instance_misc1.rs` alone. It is kept anyway because it can
//! fail on a `root_reload` regression for this shape, which is a property
//! nothing else pins. It is NOT evidence about the lowering, and naming it
//! otherwise is how a green gate stops meaning anything.

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

Update the stale module reference in the doc comment.

Line 59 says the test cannot fail on a change to expr/instance_misc1.rs alone. The two arms now live in crates/perry-codegen/src/expr/member_update.rs. The same stale path appears at line 178. Point both at expr/member_update.rs so the reasoning stays traceable after the move.

📝 Proposed doc fix
-//! `root_reload`, not because of this file's source form, and it cannot fail on
-//! a change to `expr/instance_misc1.rs` alone. It is kept anyway because it can
+//! `root_reload`, not because of this file's source form, and it cannot fail on
+//! a change to `expr/member_update.rs` alone. It is kept anyway because it can
-/// (measured — see the module header), so it cannot fail on a change to
-/// `expr/instance_misc1.rs` alone. What it can catch is a `root_reload`
+/// (measured — see the module header), so it cannot fail on a change to
+/// `expr/member_update.rs` alone. What it can catch is a `root_reload`
🤖 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/issue7628_rooting_tests.rs` around lines 55 -
62, Update the stale module references in the doc comments around the test
description and line 178, replacing expr/instance_misc1.rs with
expr/member_update.rs. Keep the surrounding explanation unchanged.

Ralph Küpper added 6 commits August 9, 2026 13:40
ES2024 evaluates the MemberExpression `arr.push` to a Reference before the
argument list, so the push lands on the array `arr` named at that moment.
Both arms of expr/array_push.rs lowered the argument first and read the
receiver afterwards, so an argument that rebound the receiver redirected the
push onto its replacement.

Gated on the divergence being observable: `push_receiver_is_rebindable` is
the as-if test, so the hot shape (a plain local nothing else can reach) keeps
its historical order, its inline tiers and its `Reuse` verdict, and emits no
rooting IR. When it fires, the spec fix and the rooting fix are one change —
the receiver becomes an operand of `with_operands_rooted_across`.

The reorder alone is not sufficient: every fast tier publishes the reallocated
head back into the binding unconditionally, which lands on the wrong array once
the argument may have rebound it. The spec-ordered arm guards its write-back on
the binding still naming the array that was pushed onto.
The member read-modify-write arms move to RootedGroup, which re-reads at any
number of caller-chosen points — the combinator #7628 asked for, already built
by #7615 slice 6.

The operand half the issue filed is NOT a live bug: with the per-use re-reads
collapsed back to one, and with PropertyUpdate's receiver root removed
entirely, the emitted IR is unchanged — root_reload (#7280) rematerialises the
slot load at each use, including through the ptrtoint + POINTER_MASK handle
derivation. Kept anyway (free, and it drops the dependence on a pass with a
documented side condition), documented as belt-and-braces, and its two tests
are named as pipeline assertions.

The repair is the RESULT: for a BigInt element js_to_numeric / js_numeric_step
return a heap BigIntHeader, and the value the expression yields is live across
a user setter as a bare call result with no slot to reload from. adopt_emitted
closes it, gated on is_provably_not_bigint so a typed-array update pays
nothing.

instance_misc1.rs was 4 lines under the 2000-line cap, so the two arms move to
expr/member_update.rs.
…mber (#6998)

`const it = u8[Symbol.iterator]` lowers to `Uint8ArrayGet { index: SymbolFor }`
— verified on the emitted HIR, which is the reachability the issue left open —
and the collector typed it Number unconditionally, so the local got no shadow
slot and the value was invisible to the collector.

The arm now answers Number only for a structurally numeric key (the same
`index_is_definitely_numeric` proof the IndexGet typed-array arm uses) and None
otherwise. Structural on purpose: a `number`-declared index local is not
evidence, and the sharper `expr_is_known_non_pointer_shadow_value` test needs an
FnCtx this collector runs before. The byte-read arm #6996 paid to keep free is
unchanged and pinned.

Note in the changelog: the runtime consequence is currently masked by a second,
behavioural defect — five other collectors make the same unconditional
assumption and force the i32 lowering, so `u8[<non-numeric key>]` reads a byte
instead of the property. Filed separately; this is a latent-soundness fix that
becomes load-bearing when that is repaired.
…dows (#6986, #7640)

#6986: three branches of lower_new_impl_inner early-return before the main
class loop adopts into the enclosing RootedGroup, so the scope was open around
them and empty — new Readline(...), new <importedFn>(...) and new Function(...)
held argument registers across each other's lowering. They now adopt as each
operand is produced (interleaved, never appended — a rooted finished list
publishes an already-dangling argument 0) and re-read at the call.

#7640 section D: unbox_str_handle calls js_get_string_pointer_unified, which
materialises an SSO value into a fresh heap StringHeader. Six sites took the
receiver's raw untagged pointer above it — #7280 taxonomy (a), which the
rooting API structurally cannot express. Four are pure statement swaps at zero
cost; two are cross-block and re-derive the handle in the string sub-block.

#7640 section B: seven arms of index_get.rs lowered a receiver, then an
unconstrained index (o[f()]), then used the receiver, with no rooting decision
at all. Each is now one with_operands_rooted group over [object, index]; where
the index provably cannot collect, operand_protection answers Reuse and the
group emits nothing.

Also fixes a migration-ledger violation the array_push tests introduced, and
drops a js_gc_temp_root_push count that reads zero on the default build (temp
roots lower to a plain alloca store in alloca mode) for the discriminating
spec-order-block assertion.

Still open on #7640: section A's typed-array stores, section C's statepoint
claim, section E's callees. Still open on #6986: builtin.rs's ~22 arms, which
need rooting threaded through lower_builtin_new's signature.
…7640)

Re-deriving `obj_handle` inside the string sub-block SHADOWED the entry
block's, which the NUMERIC sibling block still uses — and a definition in the
string block does not dominate it. The LLVM verifier rejected the module
("Instruction does not dominate all uses"), which took 10 corpus sources
including test_gap_gc_index_get_receiver_rooting out of the root-dominance
corpus as silent "skipped" entries. Distinct names; corpus back to 130/130.
@proggeramlug
proggeramlug force-pushed the fix/7634-push-receiver-order branch from a91545c to a1e1340 Compare August 9, 2026 11:43
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Re-run after rebasing onto 6086c9d40

Rebased past #7694 / #7703 / #7696; the one conflict was a neighbouring #[cfg(test)] mod line in expr/mod.rs (call_spread_rooting_tests landed on the same line as issue7628_rooting_tests), resolved by keeping both.

Re-verified on the rebased tree:

  • cargo test -p perry-codegen --lib --no-fail-fast789 pass, 0 fail (up from 786; #7696 added three).
  • All 24 lint commands + cargo fmt --all -- --check — pass.
  • The three new gap files — match node 26.5.1 byte-for-byte.
  • ./scripts/check_file_size.sh — OK.

Root dominance, shadow — 130/130 sources, 0 skipped; 40 planted, 40 caught, 0 MISSED; exit 0.

Root dominance, native — 130/130 sources, 0 skipped:

=== statepoint hazards: 8  (unrooted: 8, stale: 0)
       4  unrooted/unmasked
       2  unrooted/global
       2  unrooted/capture
within budget: unrooted 8 <= 11
within budget: stale 0 <= 0
=== seeded violations: 40 planted, 40 caught, 0 MISSED
DOM_NATIVE_EXIT=0

Note for whoever owns the ratchet: this is 8, not 11. The three unrooted/alloc hits present before the rebase are gone with #7696's spread-accumulator fix, not with anything in this PR. Lowering --max-unrooted to 8 is a separate change and belongs to that PR's follow-up rather than being smuggled in here — but leaving it at 11 means three regressions could now land green, so it should not be left long.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1397

#7634 verified independently. a.push(f()) where f rebinds a:

output
node 26.5.1 [9] [9] [7]
main [9,3] [9,3] [7,4,5]
this PR [9] [9] [7]

The push was landing on the replacement array instead of the original — a wrong answer with no diagnostic.

The finding that both the issue and my brief missed

the reorder alone is incorrect. Every fast tier publishes the reallocated head back into the binding unconditionally, so a rebinding argument makes that store overwrite the replacement.

That is the difference between a fix and a half-fix, and it is not visible from the spec text — you only see it by reading what the fast tiers do after the push. Dropping the inline tiers on the spec-ordered arm and guarding the write-back on the binding still naming the pushed array, with aliases staying valid through #233's forwarding pointer, is the right shape. The [9] [1,2] case in the gap test is the one that proves it.

Gating on push_receiver_is_rebindable as an as-if test — boxed (captured and mutated, so a captured-but-never-assigned array stays free) or module global, and the argument can collect — and then measuring zero js_gc_temp_root_push and zero spec-order blocks over a 1000-iteration loop is the right way to show a spec fix costs nothing in the common case.

#7628: your correction to my taxonomy is the durable part

case (a) "a pointer already unboxed to raw i64" is only unrepairable when a helper returns the raw handle; one masked out of a spilled NaN-boxed value is rematerialised whole.

I have been passing that taxonomy to every agent for a week. Establishing it by sabotage — collapse the re-reads, remove the receiver root, IR unchanged — rather than by argument is what makes it stick. And renaming the two non-discriminating tests as pipeline assertions rather than leaving them looking like gates is exactly right: a test that cannot fail is worse the more it looks like a guard.

The real repair being the result (case (d), no slot) for a BigInt element crossing a user setter, gated on is_provably_not_bigint with the typed-array arm as the measured counterfactual, is well-targeted.

#6998: finding the second bug is worth more than the first

typeof u8[Symbol.iterator] is number and u8["byteLength"] via a dynamic key is 0, because five other collectors force the i32 lowering — so the fix here is masked. Filing that as #7700 with the reproducer and the six sites, and not adding a gap test because it would be red, is the honest call.

The self-caught bug is the most instructive thing in the report

it presented as gc_root_dominance_corpus.sh printing 120/130 compiled, 10 skipped and exiting 0 (floor is 90) — ten sources, including test_gap_gc_index_get_receiver_rooting, silently leaving the corpus.

That is #7024's shape exactly, and it means a verifier rejection can shrink the corpus by 8% while the gate reports success. You fixed the code bug; the gate that hid it is still open, and I am closing that separately — a floor of 90 against a 130-source corpus lets a third of it disappear silently.

Gates: 24/24 lint, fmt clean, perry-codegen --lib 789, perry-runtime --lib 1938, native_root_coverage 14/14, both dominance arms 130/130 with 40/40 seeded. The native budget reads 8 after rebase (#7696 lowered it), so the concern in your report resolves.

The toSorted diff being A/B-identical against a compiler built from d1aa968e9 is the right way to dismiss a pre-existing failure — by checkout, not by inspection.

@proggeramlug
proggeramlug merged commit 9965eb3 into main Aug 9, 2026
11 of 13 checks passed
@proggeramlug
proggeramlug deleted the fix/7634-push-receiver-order branch August 9, 2026 12:10
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
…lowering (#6986)

30 arms of lower_call/builtin.rs's lower_builtin_new lowered args[0] then
args[1] (then, for several, discarded the rest for side effects) with plain
lower_expr and no rooting decision — the same #6969 shape #7699 fixed in
lower_new.rs's three non-class branches, left open by that PR for this file.
WeakMap/WeakSet are a variant: the iterable was lowered, then js_weakmap_new
(an unconditional allocation) ran, and only then was the iterable's
now-possibly-stale register read.

lower_builtin_new now takes the caller's RootedGroup (threaded in from
lower_new_impl_inner, which already opens one per #6969/#7699) and three
helpers adopt each operand into it as it is produced, never after the fact.
CronJob needed a bespoke ordering: its raw-pointer derivation can itself
allocate, so it has to run before the other two operands are re-read.

Left out of scope: the extract_options_fields-based arms (Response, Request,
Blob, File, Headers, ReadableStream, WritableStream, TransformStream) share
the hazard but are a structurally different shape.
proggeramlug added a commit that referenced this pull request Aug 9, 2026
…lowering (#6986) (#7719)

* fix(gc): root builtin.rs's constructor arguments across each other's lowering (#6986)

30 arms of lower_call/builtin.rs's lower_builtin_new lowered args[0] then
args[1] (then, for several, discarded the rest for side effects) with plain
lower_expr and no rooting decision — the same #6969 shape #7699 fixed in
lower_new.rs's three non-class branches, left open by that PR for this file.
WeakMap/WeakSet are a variant: the iterable was lowered, then js_weakmap_new
(an unconditional allocation) ran, and only then was the iterable's
now-possibly-stale register read.

lower_builtin_new now takes the caller's RootedGroup (threaded in from
lower_new_impl_inner, which already opens one per #6969/#7699) and three
helpers adopt each operand into it as it is produced, never after the fact.
CronJob needed a bespoke ordering: its raw-pointer derivation can itself
allocate, so it has to run before the other two operands are re-read.

Left out of scope: the extract_options_fields-based arms (Response, Request,
Blob, File, Headers, ReadableStream, WritableStream, TransformStream) share
the hazard but are a structurally different shape.

* chore: key the changelog fragment on PR #7719

* chore: bump version to 0.5.1416

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.

arr.push(f()) / arr.push(...g()) evaluate the receiver AFTER the argument, so a reassigning argument pushes onto the wrong array

1 participant