Skip to content

fix(repsel): resolve the growth-forwarding chain before the element-shape clone derives its base (#7480) - #7660

Merged
proggeramlug merged 8 commits into
mainfrom
fix/7480-element-shape-loop-growth-forwarding
Aug 8, 2026
Merged

fix(repsel): resolve the growth-forwarding chain before the element-shape clone derives its base (#7480)#7660
proggeramlug merged 8 commits into
mainfrom
fix/7480-element-shape-loop-growth-forwarding

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Re-measuring #7480 to decide its remaining step found a shipped SIGBUS in
#7612's element-shape versioned loop clone. The perf work #7480 describes is
not what this PR does — it fixes the crash that consumer has on main, and
re-measures the issue so its next step is chosen against real numbers. Details
of the measurement are in the issue comment; the fix is below.

The bug

js_array_grow does not grow in place. It allocates the larger array
elsewhere, copies, and leaves a forwarding stub at the old address —
and the stub's first payload word (lengthcapacity) is overwritten with
the new head
, which is how clean_arr_ptr follows the chain. Every runtime
entry point resolves that chain, so a binding holding a stale head still
behaves correctly through the runtime. Only bindings the growing code itself
wrote through get re-pointed; a callee that grows a caller's array, or a
function that grows a local and returns it, leaves stale heads behind. That is
repsel 4a.2's (#6904) canonical case, and it already has a fix:
js_array_refresh_local_head.

Every other consumer of a raw array head calls it. The element-shape preheader
did not, and derived both the bound check and the elements base straight from
the raw pointer:

%r50 = inttoptr i64 %handle to ptr
%r51 = load i32, ptr %r50            ; "length"
%r52 = icmp uge i32 %r51, %bound     ; the prefix-covers-the-range check
%r53 = add i64 %handle, 8            ; elements base

On a stub those two facts fail in the worst possible combination. length
reads the low 32 bits of a heap pointer — a huge number, so len_ok passes
— while the base still addresses the pre-growth buffer. Meanwhile the guard
call one block earlier resolved the chain internally and answered truthfully
about the live array, so the class-id test passed too.

The clone then reads correct elements up to the old capacity and runs off the
end of the block after it. MIN_ARRAY_CAPACITY == 16, so:

sweep bound:  1   16  |  17  100  1000
              ok  ok  |  SIGBUS

Reproducing it — two ingredients, and one of them is not obvious

A first attempt to reproduce this failed, and the reason is worth stating up
front because it defines the blast radius.

  • (A) The clone has to be emitted at all. match_element_shape_versioned_loop
    accepts a loop bound that is Expr::Integer or Expr::LocalGet and nothing
    else. An inline arr.length bound is an Expr::PropertyGet, so the matcher
    declines and the binary contains no clone. for (let j = 0; j < keep.length; j++) therefore cannot fault; hoisting the identical bound into a local
    (const len = keep.length) is enough to flip it.
  • (B) The binding has to hold a stub — the array grew past
    MIN_ARRAY_CAPACITY (16) inside a callee that returned it. Minimal N = 17.

Not required, each checked on main @ 384eba78b:

  • the profile--profile perry-dev and --release produce the same
    table, same minimal N;
  • PERRY_NO_AUTO_OPTIMIZE=1 — it faults with auto-optimize on too;
  • any run-time env var — none is needed and none suppresses it (the only
    suppressor is PERRY_GC_MOVING_LOOP_POLLS=1 at compile time, which makes
    the clone stand down by construction — that is how I localised it);
  • crossing a function boundary at the read site — a module-scope loop with
    a hoisted bound faults identically. I originally guessed this was
    load-bearing; the isolation arms below disprove it, and it is corrected here
    and in array growth forwarding: build()-and-return leaves a stale head in the binding (minimal N = 17) #7661.
arm loop location loop bound N=16 N=17
function, array as param function local n 0 138
module scope module keep.length 0 0
module scope module local len 0 138
function, array as param function keep.length 0 0
module scope + push/pop write-back module local len 0 0

The bound form is load-bearing; the loop's location is not. The last row is the
stub proof: a keep.push(x); keep.pop(); that changes no contents but routes
through the runtime push helper — which resolves the chain and writes the live
head back — exits 0 with the right answer at exactly the N where the same file
without it faults.

Minimal reproducer, exit 138 (EXC_BAD_ACCESS / SIGBUS) on main:

class Node { v: number; w: number; constructor(v: number, w: number) { this.v = v; this.w = w; } }
function build(n: number): Node[] {
  const out: Node[] = [];
  for (let i = 0; i < n; i++) out.push(new Node(i, i * 2));
  return out;                       // `out` grew: 16 -> 32 -> ... , stale head returned
}
function sweep(keep: Node[], n: number): number {
  let sum = 0;
  for (let j = 0; j < n; j++) sum += keep[j].v;   // the clone
  return sum;
}
const keep = build(17);                           // 17, not 1000 — 16 is fine
console.log(sweep(keep, keep.length));            // bus error, no output at all

Note sweep's bound is the parameter n (an Expr::LocalGet). Change it to
j < keep.length and the same file exits 0 — see ingredient (A).

PERRY_PTR_SHAPE_LOCALS=0, PERRY_GEN_GC=0, PERRY_GEN_GC_EVACUATE=0 and
PERRY_WRITE_BARRIERS=0 all still crash: it is not GC movement and not 3b, it
is growth forwarding.

That keep really is a stub rather than a live head is established two
ways, not assumed. Directly: adding a module-scope keep.push(…); keep.pop();
after the build() — which forces a write-back of the resolved head into the
global — makes the same pre-fix compiler print the right answer and exit 0.
And by construction: js_array_refresh_local_head returns its input untouched
when there is nothing to follow, so if the binding were already live this PR
would be a no-op and the crash would survive it.

Note for a follow-up, out of scope here: something on the producer side is
leaving that stub reachable — expr/array_push.rs does write the reallocated
head back to the pushing scope's own slot, so the value that reaches keep is
losing it somewhere between build's slot and the caller's binding. This PR
fixes the consumer, which is the right layer regardless (a stale head can
arrive from any of several routes, which is why every runtime entry point
resolves the chain), but the producer gap is tracked as #7661.

Why every existing test missed it. Both the gap test and the codegen census
build their arrays in the same scope that reads them, so the binding always
held the live head, and the largest was 64 elements pushed at module scope —
where each push's write-back updates the global. The raw-pointer derivation
was never handed a stub.

The fix

A element_shape.loop.preheader.repair block between the brand test and the
guard call: follow the chain once with js_array_refresh_local_head, write the
live head back into the binding, re-derive the tag/band predicate from it.

Ordering is the substance, and it is why the repair is a new block rather than
two lines in the existing deref block:

  • It cannot go after the guard call. The deref block's contract is "no call
    from here to the end of the clone" — that is the revocation argument. The
    refresh can allocate (clean_arr_ptr force-materialises a lazy array), so a
    refresh there would reintroduce exactly the "base derived across an
    allocating call" hazard step (4) exists to prevent.
  • The write-back is what makes it work at all. The query and deref blocks
    both re-read the binding — deliberately, because the guard call can move
    the array. Refreshing without storing back would leave both re-reads pulling
    the stub straight out again.

The write-back also lands the durable half of #6904's self-heal: after the
first visit the binding holds the live head, so every later loop entry, and the
slow clone, address the current array directly.

stmt/element_shape_loop.rs additionally declines closure-captured arrays,
which have a capture cell a plain slot store would not update.

Tests

Gap suitetest_gap_repsel_element_shape_loop_clone.ts gains case 10
with both stale-head shapes above 16 elements: callee-builds-and-returns, and
callee-grows-the-caller's-array. Both bound forms are covered — a parameter
bound (sumField(returned, returned.length), whose loop reads n) and a
module-scope loop with a literal bound — so a future narrowing of ingredient
(A) cannot silently drop the coverage. Verified in both directions:

arm result
main (ec675f2fe, and 384eba78b) exit 138, SIGBUS partway through the output
this branch byte-identical to node --experimental-strip-types

Standalone reproducers, both profiles, live at /tmp/s7480-real/ with a
run_all.sh that prints the full N-scan per arm.

Codegen census — three tests in element_shape_loop_tests.rs. The
load-bearing one asserts the stored-back value is the refresh's result, not
merely that a store exists; sabotage-checked by deleting the write-back arm,
which fails that test and only that test. CLONE_LABELS gains the repair
block, so a silently-dropped repair fails the census.

Gates

22/22 lint commands (extracted from test.yml), cargo fmt --all --check,
cargo test -p perry-codegen --lib 728 passed, cargo test -p perry-runtime --lib 1917 passed, cargo check --all-targets, native_root_coverage 14
passed. Codegen changed, so gc_root_dominance_check.py --moving-only --seeded-violations 40 over a freshly built 149-module corpus: 0 violations,
40/40 seeded caught, 9828 root stores
— the checker was live.

The repair introduces a call into the preheader, so the gap test was also run
under seven GC configurations, all byte-identical to node: PERRY_GC_ZEAL=1,
PERRY_GC_FORCE_EVACUATE=1, PERRY_GEN_GC=0, PERRY_GEN_GC_EVACUATE=0,
PERRY_GC_VERIFY_EVACUATION=1, PERRY_GC_FROMSPACE_SCAN_ABORT=1,
PERRY_WRITE_BARRIERS=0.

Gap-suite subset as the #6377 check (repsel / array / class / object /
new / computed / prop — 76 tests, node 26.5.1): 75 pass, 0 fail, 1
node_fail (test_gap_prop_plan_cache_invalidation; node itself exits
non-zero, so not reachable from a compiler change).

Measured (pinned quiet mini, 7 interleaved rounds, checksums equal everywhere)

200k elements × 50 sweeps, runtime-derived bounds (process.argv), release
build, PERRY_NO_AUTO_OPTIMIZE=1, PERRY_RUNTIME_DIR pinned per arm.

kernel perry main perry this PR node bun
keep: Node[] — the clone's own shape SIGBUS 13 ms (12–13) 57 15
keep: {v,w}[]#7480's kernel, out of the clone's reach 414 ms (414–415) 414 ms (413–414) 12 12

Two controls, because "the guard now declines" would look identical to "fixed"
on the crash test alone:

The {v,w}[] row is #7480's remaining work and is untouched here:
element_class_name resolves Array(Named(C)) only, so an object-literal
element type never reaches the clone. The issue's recorded 93 ms / 6.2× is
stale in the optimistic direction; engine-plan item 6 is updated with the
current table, including the reason node's own baseline differs 5× between the
two kernels (v: number; survives type-stripping as a class field declaration,
pinning the field to tagged representation).

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The element-shape loop preheader repairs forwarded array heads, stores refreshed bindings, validates representations, and reloads handles before shape queries. Closure-captured arrays are excluded. Tests cover IR ordering, fast-clone boundaries, growth, and callee mutation.

Changes

Element-shape repair

Layer / File(s) Summary
Repair and reload array bindings
crates/perry-codegen/src/expr/element_shape_guard.rs
The preheader refreshes stale array heads, stores live pointers, validates them, and reloads the binding before js_array_ensure_element_shape.
Guard closure-captured arrays
crates/perry-codegen/src/stmt/element_shape_loop.rs
The matcher rejects closure-captured arrays before applying loop-invariance checks.
Validate repair ordering and growth handling
crates/perry-codegen/src/stmt/element_shape_loop_tests.rs, test-files/test_gap_repsel_element_shape_loop_clone.ts
Tests verify repair ordering, binding stores, fast-clone boundaries, grown-array reads, repeated queries, and caller-owned arrays.
Document growth-forwarding behavior
changelog.d/7660-element-shape-loop-growth-forwarding.md, docs/engine-plan.md
The changelog and engine plan record the stale-head failure, repair behavior, regression coverage, benchmark measurements, and remaining repsel work.

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

Possibly related issues

  • PerryTS/perry issue 7480: Defines the element-shape loop guard and array-shape validation extended by this change.
  • PerryTS/perry issue 7661: Tracks the separate producer-side stale array-head write-back gap documented by this change.

Possibly related PRs

  • PerryTS/perry#7496: Introduces the per-array element-shape invariant consumed by this repair path.
  • PerryTS/perry#7612: Adds the related element-shape loop guard and clone implementation extended here.
  • PerryTS/perry#6916: Modifies representation selection for optimized numeric-array element access in related code.

Sequence Diagram(s)

sequenceDiagram
  participant LoopMatcher
  participant RepairPreheader
  participant ArrayRuntime
  participant ShapeGuard
  participant FastClone
  LoopMatcher->>RepairPreheader: emit repair path for eligible array
  RepairPreheader->>ArrayRuntime: refresh forwarded array head
  ArrayRuntime-->>RepairPreheader: return live head
  RepairPreheader->>LoopMatcher: store and reload binding
  LoopMatcher->>ShapeGuard: query element shape with refreshed handle
  ShapeGuard->>FastClone: branch to fast clone
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the repsel fix for growth forwarding before element-shape clone base derivation.
Description check ✅ Passed The description thoroughly covers the bug, fix, tests, validation gates, measurements, and related issues, although it omits the template headings.
✨ 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 fix/7480-element-shape-loop-growth-forwarding

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: 2

🤖 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 `@docs/engine-plan.md`:
- Around line 487-490: Update the named-class arm description to use the stable
phrase “before `#7660`” or cite the exact pre-fix revision, replacing the
time-dependent “on main” reference while preserving the SIGBUS and
growth-forwarding context.
- Around line 474-475: Clarify the benchmark ratio in docs/engine-plan.md by
renaming the ratio column or adding a statement before the table that defines it
as perry/node. Ensure the examples and all existing ratio values remain
consistent with that denominator.
🪄 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: d04c8b36-c874-4c61-bf25-bb5e78617636

📥 Commits

Reviewing files that changed from the base of the PR and between 79c5038 and 2426f1e.

📒 Files selected for processing (1)
  • docs/engine-plan.md

Comment thread docs/engine-plan.md
Comment on lines +474 to +475
| `keep: Node[]` — what #7612 covers | 13 ms‡ | 57 ms† | 15 ms | **0.23×** |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Define the benchmark ratio.

The table contains both Node and Bun, but ratio does not identify its denominator. The values indicate perry/node (414 / 12 = 34.5; 13 / 57 = 0.23). Rename the column or state the formula before the table.

🤖 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 `@docs/engine-plan.md` around lines 474 - 475, Clarify the benchmark ratio in
docs/engine-plan.md by renaming the ratio column or adding a statement before
the table that defines it as perry/node. Ensure the examples and all existing
ratio values remain consistent with that denominator.

Comment thread docs/engine-plan.md
Comment on lines +487 to +490
‡That cell did not exist before #7660: on `main` the named-class arm
*SIGBUSes*, because the versioned-loop consumer derived its elements base
from an unresolved growth-forwarding stub for any array grown outside the
scope that reads it. Step 2's win was real but gated behind a crash.

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

Use a stable pre-fix reference instead of “on main”.

After #7660 lands, “on main the named-class arm SIGBUSes” becomes false. Refer to “before #7660” or identify the exact pre-fix revision used for the comparison.

🤖 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 `@docs/engine-plan.md` around lines 487 - 490, Update the named-class arm
description to use the stable phrase “before `#7660`” or cite the exact pre-fix
revision, replacing the time-dependent “on main” reference while preserving the
SIGBUS and growth-forwarding context.

Ralph Küpper added 8 commits August 8, 2026 22:03
…hape clone derives its base (#7480)

The element-shape versioned loop clone (#7612) derived `length` and the
elements base from the array binding's RAW pointer. `js_array_grow` moves the
array and leaves a forwarding stub whose first payload word (length‖capacity)
is overwritten with the new head, so on a stale binding `length` read the low
half of a heap pointer — passing the bound check — while the base still
addressed the pre-growth buffer. Correct up to MIN_ARRAY_CAPACITY (16), SIGBUS
at 17.

Repairs the binding with repsel 4a.2's `js_array_refresh_local_head` before the
guard call (the refresh can allocate, so it cannot go after the base is
derived), and writes the live head back so the existing post-call re-loads pick
it up.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1375. This is a live SIGBUS on main.

Reproduced independently. Your exact files, my own main build (perry-dev, 384eba78b), PERRY_NO_AUTO_OPTIMIZE=1:

c_minimal_n                     N=16:0  N=17:138  N=1000:138
e_module_scope_local_bound      N=16:0  N=17:138  N=1000:138
d_module_scope_loop             N=16:0  N=17:0    N=1000:0
f_function_inline_length_bound  N=16:0  N=17:0    N=1000:0

138 = SIGBUS, at exactly one past MIN_ARRAY_CAPACITY. On the fix, all five arms exit 0 and match node byte-for-byte at N=16/17/1000.

And the clone is still entered — the check that separates a fix from a suppression. Both arms emit 7 versioned-clone blocks; the fix adds element_shape.loop.preheader.repair calling js_array_refresh_local_head before the base is derived, storing the result back through the rooted binding (store ptr addrspace(1) …) — correct, since the refresh can allocate and two later blocks re-read it. A fix that worked by making the guard decline would have removed the crash and the optimisation together.

Why I couldn't reproduce it at first, and why that matters for the writeup

My kernel used an inline keep.length bound. match_element_shape_versioned_loop accepts only Expr::Integer or Expr::LocalGet, so a PropertyGet bound is rejected and no clone is emitted at all — my binary never contained the code that crashes. Your e vs f isolation is what proves the bound form is load-bearing and the function boundary is not.

You corrected your own trigger description rather than defending it, and that is the important part: "any array grown outside the reading scope" was wrong, #7661's title said "hands the caller a stale head" and e is a module-scope const that never crosses a boundary. Retitling the issue and rewriting the fragment on your own finding is worth more than the original report was. ge plus a push/pop no-op that routes through the runtime helper and resolves the chain — is the cleanest possible stub proof.

The stale-figure count is now five, and this is the first that flattered

93 ms / 6.2× → 414 ms / 34.5×. Every previous stale headline overstated the problem; this one understated it by 4.5×. That is a distinct hazard from the four already recorded, and worth the plan note.

Two more things the re-measure surfaced that the issue had wrong:

  • The cost model does not describe this kernel. The issue says "no out-of-line guard calls, the cost is stacked inline diamonds". The object-literal path carries three calls per iteration (js_typed_feedback_observe_property_get, js_typed_feedback_record_guard_pass on the hit path, js_dynamic_string_or_number_add — the accumulator loses its numeric proof so + isn't an fadd). Separable lever, correctly not taken here.
  • node is 5× slower on the class arm than the literal arm (57 vs 12 ms), because v: number; cannot be erased — V8 pre-initialises the slot and pins tagged representation. "Beat node" is two different numbers depending on the arm, which is the acceptance-floor hazard in a new form.

Corrections to my brief, both yours and both right

"Hash both arms' .a and assert they differ" is meaningless for a codegen-only change — the runtime source is identical, so the archives should match. Emitted-IR cmp is the right control, and k_literal being byte-identical across arms is what makes 414-vs-414 identity rather than luck. And Route A partly exists already (collectors/ptr_shape_elements.rs, #7034 §3) for region-local arrays; the open work is the parameter/global case.

Your fmt catch is the best self-correction of the day: rustup run stable cargo fmt --all -- --check reporting 0 because $? was a downstream tail's, with the committed tree actually unformatted and lint about to go red. Sixth instance of that shape today, and the only one caught by the person who made it.

Gates: 22/22 lint, fmt clean, perry-codegen --lib 728, perry-runtime --lib 1917, native_root_coverage 14/14, and the new gap test is registered in gc_repsel_corpus.txt — so it will actually run under zeal rather than being a dark test.

@proggeramlug
proggeramlug force-pushed the fix/7480-element-shape-loop-growth-forwarding branch from aa21ad8 to 6eeec68 Compare August 8, 2026 20:10
@proggeramlug
proggeramlug merged commit 041b6a7 into main Aug 8, 2026
@proggeramlug
proggeramlug deleted the fix/7480-element-shape-loop-growth-forwarding branch August 8, 2026 20:10
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