Skip to content

fix(gc): scalar-replaced object/array locals are not precise roots (#6968) - #7007

Merged
proggeramlug merged 4 commits into
mainfrom
fix/6968-scalar-replaced-local-rooting
Jul 29, 2026
Merged

fix(gc): scalar-replaced object/array locals are not precise roots (#6968)#7007
proggeramlug merged 4 commits into
mainfrom
fix/6968-scalar-replaced-local-rooting

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #6968.

The defect

With the conservative native-stack scan disabled — i.e. with precise (shadow-stack) roots only — a heap value stored into a scalar-replaced object field or array element was swept out from under the alloca that held it.

Scalar replacement deletes the object and keeps one entry-block alloca per field/element. Those allocas belong to no HIR local, so collect_pointer_typed_locals — which assigns shadow slots by walking Stmt::Let — never saw them, and nothing ever called js_shadow_slot_bind for them:

%r13 = call double @..._fresh__spec_i32(i32 0)
store double %r13, ptr %r10                   ; o.a — a bare, unrooted alloca
%r16 = call double @..._churn(double %r15)    ; collects; %r10's string is swept
store double %r16, ptr %r11                   ; o.b

#6951/#6972's object-literal fix cannot reach this shape. That path roots the object handle; here there is no handle. The object local does get a shadow slot reserved (it is pointer-typed), but lowering only ever clears it — main's frame in the repro pushed 7 slots and emitted js_shadow_slot_set(i, 0) for them and nothing else.

Reproduction (confirmed, aa1c15028)

$ PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off ./repro
 417894          <- o.a gone
 421991
 417894

Both the object and the array form. Isolated with four variants, all on the same acceptance arm:

variant result what it proves
{ const o = { a: fresh(k), b: churn(N) }; log(o.a, o.b) } FAIL the reported defect
const a = [fresh(k), churn(N)]; log(a[0], a[1]) FAIL array form
const o = {…}; const s = o.a; const n = o.b; log(s, n) FAIL the value is already dead before the read — the alloca is the unrooted root, not the console.log argument path
const s0 = fresh(k); const n0 = churn(N); const o = { a: s0, b: n0 } PASS an ordinary pointer local's shadow slot works
const s = fresh(k); const n = churn(N); log(s, n) PASS console.log argument temporaries are fine (#6972 holds)

The design choice, and what it costs

Three shapes were on the table. Suppressing scalar replacement when a replaced slot is pointer-capable and live across a safepoint is provably sound but gives the optimization back exactly where it earns most (measured below: ~32× on the shape in question). Rooting only across collecting safepoints needs a liveness analysis over storage that has no HIR name.

This PR takes the third: shadow-bind the replaced slot, lazily and only when the store can be a heap reference — the same treatment emit_shadow_slot_update_for_expr already gives an ordinary pointer-typed local, which is what the issue asks for.

Two mechanisms make it fit:

  • The shadow frame grows on demand. LlFunction::reserve_shadow_slot rewrites the slot-count operand of the already-emitted js_shadow_frame_push line (and creates the frame if the pre-lowering count was 0). The alternative — teaching collect_pointer_typed_locals to predict every scalar-replacement decision — would have to re-derive conditions the collector never evaluates, and the escape facts are not even computed until after the frame is sized.
  • Reservation is lazy. A slot is taken at the first store the gate cannot prove non-pointer. A literal whose fields are all numbers takes no slot, emits no call, and does not grow the frame — verified in both directions by unit tests.

The gate is decided from the lowering, never the declared type (#6997): expr_is_known_non_pointer_shadow_value, the same predicate that decides whether an ordinary pointer local's slot is bound or cleared. Nothing here consults a field's : string / : number annotation, which Perry does not enforce. The one place a declared type is consulted is numeric_store in expr::property_set, and that arm is skipped, not trusted: those bits are a canonicalized raw f64 by construction.

Sites instrumented: object-literal fields, array-literal elements, scalar-replaced split() parts (js_string_split_part_value returns a fresh heap string with nothing else referring to it), anonymous-shape constructor arguments, and both property_set arms (o.f = … and an inlined ctor's this.f = …).

Measured cost

Release build, macOS arm64, 5 iterations, perry-baseline vs perry-fixed from the same tree:

loop baseline with fix delta
{ x: i & 1023, y: (i>>3) & 1023 }, 40 M iters 313–355 ms 319–353 ms none (gate skips both fields)
[i & 1023, (i>>3) & 1023], 40 M iters 275–288 ms 275–285 ms none
{ s: mkStr(i), y: (i>>3) & 1023 }, 10 M iters 118–121 ms 144–148 ms +~26 ms ≈ +2.6 ns per pointer-capable store

Only s is rooted in the third loop; y is skipped, so the emitted bind sites go 3 → 4 for the whole file.

Context for that +2.6 ns — the same literal with the object made to escape, i.e. the heap object scalar replacement removes:

time (10 M iters)
scalar-replaced with rooting 148–163 ms
real heap object 4682–4997 ms

Scalar replacement still wins by ~32× after paying for the root. That is the honest reason this PR keeps the optimization rather than suppressing it.

What it does not do: the bind is emitted per store site, so a pointer-capable field store inside a loop re-binds each iteration. Hoisting it to entry setup (persistent-slot style: one bind + a cheap incremental root barrier per store) would remove that call, but the barrier helper lives in expr/shadow_slot.rs, which #6997 is currently editing. Left as a follow-up rather than merged into a fenced file.

Results against #6981's corpus

scripts/gc_repsel_matrix.sh --arms evac_minor,cons_scan_off --pressure 8, release, pinned Node 26.5.0, same tree, baseline vs fixed:

Exactly one cell flips: test_gap_repsel_gc_stress on evac_minor, FAIL → PASS. No cell regresses. Deterministic over 3 repeats (baseline MISMATCH ×3, fixed MATCH ×3, moved=1 230 900 in every run — the evacuating minor relocated the same volume in both arms, so this is a rooting fix, not an evacuation that stopped happening).

Per file, for the 13 distinct files #6981 lists as red — with the emitted LLVM IR diffed between the two compilers:

file this fix IR changed?
repsel_gc_stress FIXED yes, 12 → 15 binds
repsel_ptr_shape_locals still red (SIGBUS, byte-identical output to baseline) yes, 109 → 112 binds
repsel_p4b_field_store_elision still red (4010890NaN, byte-identical to baseline) yes, 60 → 100 binds
repsel_canonical_i32 not this defect IR identical
specabi_polymorphic_coexist not this defect IR identical
specabi_reassign not this defect IR identical
ta_param_numeric_read not this defect IR identical
typedarray_param_read not this defect IR identical
repsel_ptr_shape_barriers not this defect IR identical
repsel_p4a_inline_tiers not this defect IR identical
repsel_p4a_logical_numeric not this defect IR identical
repsel_p4a3_numarray_barriers not this defect IR identical
repsel_p4a3_ptr_numarray not this defect IR identical
repsel_proven_this_frozen not this defect IR identical

10 of the 13 compile to byte-identical LLVM IR with and without this change, so #6968 provably cannot be their cause. The hypothesis that #6968 accounted for 7 of them does not survive measurement — #6981's own minimal reproducer for specabi_reassign is a typed array passed under the specialized ABI, which is the argument-passing family (#6969/#6970/#6971), not scalar replacement. The two files this PR does touch without fixing produce output byte-identical to the baseline, so their remaining redness is a different defect layered on the same file (p4b's is a corrupted number, which no missing root produces).

Acceptance evidence

Every claim above was measured on the evacuating precise-roots arm:

PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off

with [gc-copy-minor] ran copied_objects=N, N > 0, on every run — 8 cycles up to copied_objects=157 385 on the issue repro, moved=1 230 900 on gc_stress, moved=1 501 960 on the new corpus member. No green-because-nothing-moved runs are reported here.

Tests

test-files/test_gap_repsel_scalar_replaced_locals.ts — new corpus member, registered in test-parity/gc_repsel_corpus.txt. Object literal, array literal, post-construction o.a = …, plus the numeric-only twin that must stay free. Teeth verified in both directions, 3 repeats each:

arm baseline fixed
cons_scan_off (a PR arm) MISMATCH ×3 MATCH ×3
evac_minor MISMATCH ×3 MATCH ×3, moved=1 501 960
default / shipped_default MATCH MATCH

It is GC-live by construction: one of only two corpus members that collect at all (35 cycles under --pressure 8), so it PASSes rather than UNVERs on every PR arm. scripts/gc_repsel_matrix.sh --arms pr --pressure 8 is FAIL=0, 88/88 cells byte-exact vs Node 26.5.0.

crates/perry-codegen/tests/scalar_replaced_slot_roots.rs — 5 codegen-contract tests, run per-PR by the e2e-scoped job (#5960). Teeth verified in both directions:

  • against crates/perry-codegen/src reverted to origin/main, the 3 positive tests FAIL and pass with the fix;
  • the 2 gate tests (numeric_only_*_emits_no_rooting) cannot fail pre-fix — nothing was rooted then — so their teeth were verified the other way: with the expr_is_known_non_pointer_shadow_value gate deliberately removed, both FAIL.

Not fixed here, found along the way

An escaping object literal loses o.a on the same arm ({ const o = { a: fresh(k), b: churn(N) }; keep.push(o); log(o.a, o.b) }, first 2 of 6 iterations). That is the heap object-literal path, not scalar replacement — this PR does not change it and does not claim it.

scripts/check_file_size.sh is red on this tree for 9 files, all byte-identical to origin/main and none touched here.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed GC rooting for scalar-replaced object fields and array elements so heap references stored there remain valid during collection.
    • Improved rooting for common scalar-replacement patterns, including object/array literals, split results, property set, and constructor inlining, while avoiding work for numeric-only values.
  • Tests

    • Added new regression and codegen inspection tests to verify correct js_shadow_slot_bind/shadow frame behavior for scalar-replaced roots, including precise-root mode coverage.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 61e12b87-b283-4477-9570-df732eca3976

📥 Commits

Reviewing files that changed from the base of the PR and between b96fcb5 and 53f3b71.

📒 Files selected for processing (13)
  • changelog.d/7007-scalar-replaced-local-rooting.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/property_set.rs
  • crates/perry-codegen/src/expr/scalar_slot_root.rs
  • crates/perry-codegen/src/function.rs
  • crates/perry-codegen/src/stmt/let_stmt.rs
  • crates/perry-codegen/tests/scalar_replaced_slot_roots.rs
  • test-files/test_gap_repsel_scalar_replaced_locals.ts
  • test-parity/gc_repsel_corpus.txt
📝 Walkthrough

Walkthrough

Scalar-replaced object fields and array elements are now bound to precise GC shadow slots when storing heap values. Shadow frames grow lazily, numeric-only stores avoid bindings, and codegen plus runtime regression tests cover the supported lowering paths.

Changes

Scalar-replaced GC rooting

Layer / File(s) Summary
Scalar slot context initialization
crates/perry-codegen/src/expr/mod.rs, crates/perry-codegen/src/codegen/*
FnCtx tracks scalar-replaced shadow slots, and all closure, module, function, method, and static-method construction paths initialize the map.
Lazy shadow-frame reservation
crates/perry-codegen/src/function.rs
Shadow-frame requests can remain empty initially; later slot reservations emit or update the frame push count in place.
Scalar-replaced store rooting
crates/perry-codegen/src/expr/scalar_slot_root.rs, crates/perry-codegen/src/expr/property_set.rs, crates/perry-codegen/src/stmt/let_stmt.rs
New helpers bind scalar-replaced allocas conditionally or unconditionally, covering property, constructor, split, array, object, and anonymous-shape stores.
Rooting regression coverage
crates/perry-codegen/tests/scalar_replaced_slot_roots.rs, test-files/test_gap_repsel_scalar_replaced_locals.ts, test-parity/gc_repsel_corpus.txt, changelog.d/7007-scalar-replaced-local-rooting.md
IR tests verify heap-value bindings, numeric-only omissions, and later stores; a GC stress test and corpus entry cover object and array locals.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6997: Both changes use known non-pointer shadow values to decide whether precise rooting is required.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title names the main fix and issue number, matching the scalar-replaced precise-root bug.
Description check ✅ Passed The description covers the defect, rationale, changes, related issue, and test evidence, though it doesn't follow the template headings exactly.
Linked Issues check ✅ Passed The changes add shadow-slot binding for scalar-replaced heap-bearing slots and keep numeric-only stores unrooted, matching #6968.
Out of Scope Changes check ✅ Passed The refactor, helper additions, and tests all support the precise-root fix; no unrelated changes stand out.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/6968-scalar-replaced-local-rooting
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6968-scalar-replaced-local-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.

@proggeramlug
proggeramlug force-pushed the fix/6968-scalar-replaced-local-rooting branch from 8b4fcb8 to b96fcb5 Compare July 29, 2026 14:02

@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 (1)
crates/perry-codegen/tests/scalar_replaced_slot_roots.rs (1)

249-328: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Cover split() and anonymous-shape constructor stores.

The stated rooting scope includes these paths, but neither the IR contracts nor the runtime repro exercises them.

  • crates/perry-codegen/tests/scalar_replaced_slot_roots.rs#L249-L328: add IR assertions that heap-valued split() parts and anonymous-shape constructor arguments emit scalar-slot bindings.
  • test-files/test_gap_repsel_scalar_replaced_locals.ts#L41-L65: add churn-based runtime cases that read those values after allocation pressure.

Based on PR objectives, instrumentation covers “split() parts” and “anonymous-shape constructor arguments.”

🤖 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/tests/scalar_replaced_slot_roots.rs` around lines 249 -
328, The scalar-replaced rooting tests omit split() parts and anonymous-shape
constructor stores. In
crates/perry-codegen/tests/scalar_replaced_slot_roots.rs:249-328, add IR
assertions confirming heap-valued split parts and anonymous-shape constructor
arguments emit scalar-slot bindings; in
test-files/test_gap_repsel_scalar_replaced_locals.ts:41-65, add churn-based
runtime cases that read those values after allocation pressure.
🤖 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/tests/scalar_replaced_slot_roots.rs`:
- Around line 209-213: Update the assertion in the scalar-replacement test so it
verifies the additional shadow-frame slot reserved for the scalar binding,
rather than merely checking that the frame is nonempty. Compare
frame_slot_count(&ir) with an equivalent numeric-only control or assert the
expected increment, while preserving the existing diagnostic context.

---

Nitpick comments:
In `@crates/perry-codegen/tests/scalar_replaced_slot_roots.rs`:
- Around line 249-328: The scalar-replaced rooting tests omit split() parts and
anonymous-shape constructor stores. In
crates/perry-codegen/tests/scalar_replaced_slot_roots.rs:249-328, add IR
assertions confirming heap-valued split parts and anonymous-shape constructor
arguments emit scalar-slot bindings; in
test-files/test_gap_repsel_scalar_replaced_locals.ts:41-65, add churn-based
runtime cases that read those values after allocation pressure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cf4b9e4b-879e-463e-93d1-21b19caf48f2

📥 Commits

Reviewing files that changed from the base of the PR and between 5d322b4 and ba2821a.

📒 Files selected for processing (13)
  • changelog.d/7007-scalar-replaced-local-rooting.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/property_set.rs
  • crates/perry-codegen/src/expr/scalar_slot_root.rs
  • crates/perry-codegen/src/function.rs
  • crates/perry-codegen/src/stmt/let_stmt.rs
  • crates/perry-codegen/tests/scalar_replaced_slot_roots.rs
  • test-files/test_gap_repsel_scalar_replaced_locals.ts
  • test-parity/gc_repsel_corpus.txt

Comment thread crates/perry-codegen/tests/scalar_replaced_slot_roots.rs
@proggeramlug
proggeramlug force-pushed the fix/6968-scalar-replaced-local-rooting branch from b96fcb5 to 53f3b71 Compare July 29, 2026 14:29
@proggeramlug
proggeramlug merged commit 393a36a into main Jul 29, 2026
6 checks passed
@proggeramlug
proggeramlug deleted the fix/6968-scalar-replaced-local-rooting branch July 29, 2026 14:29
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Correction: re-measured after the rebase — the per-file table in the PR body is stale

The body's per-file results were measured against aa1c15028. Main moved under this branch (#6990, #6994, #6983, #6997 all landed), so I rebuilt both compilers from source and re-ran the matrix with the baseline at the actual merge parent, d30f3bea8. The picture changes materially and in the direction that would have been an overclaim, so recording it here.

scripts/gc_repsel_matrix.sh --arms evac_minor,cons_scan_off --pressure 8 --no-build, release, macOS arm64, pinned Node 26.5.0, both compilers built from source in the same tree with the same package set:

PASS UNVER FAIL
baseline d30f3bea8 19 22 5
this PR 22 22 2

The five red cells at baseline: repsel_p4a3_numarray_barriers (evac), repsel_p4a3_ptr_numarray (evac), repsel_gc_stress (evac), and both cells of the new repsel_scalar_replaced_locals. Only the last three flip.

#6981's 13 distinct files, as of d30f3bea8

outcome count files
already repaired before this PR, by #6983 / #6990 / #6994 / #6997 11 repsel_canonical_i32, specabi_polymorphic_coexist, specabi_reassign, ta_param_numeric_read, typedarray_param_read, repsel_ptr_shape_locals, repsel_ptr_shape_barriers, repsel_p4a_inline_tiers, repsel_p4a_logical_numeric, repsel_p4b_field_store_elision, repsel_proven_this_frozen
repaired by this PR 1 repsel_gc_stress
still red, not this defect 2 repsel_p4a3_numarray_barriers, repsel_p4a3_ptr_numarray

So the honest count is 1 of #6981's files, not the 7 the issue hypothesised and not the "1 of 14" the body reported against the older base — the denominator itself had already shrunk. The body's supporting evidence still holds and is what made the conclusion safe to state either way: 10 of the 13 compiled to byte-identical LLVM IR with and without this change, so #6968 could never have been their cause.

Everything else re-verified against d30f3bea8

All on the evacuating precise-roots arm, copied_objects > 0 on every run, diffed against the pinned Node 26.5.0 oracle (not exit codes):

  • Issue repro, both forms — baseline MISMATCH / fixed MATCH, moved≈1.08–1.10 M. Compiled with auto-optimize (the shipped link path), unlike the body's first measurement.
  • test_gap_repsel_scalar_replaced_locals — 3 repeats × 2 arms, baseline MISMATCH ×6 / fixed MATCH ×6; cons_scan_off (a PR arm) moved=0 but 35 cycles, evac_minor moved=1 501 975.
  • PR arm set--arms pr: FAIL=0, 29 PASS. The new member is one of only two corpus files that collect at all, so it PASSes rather than UNVERs on all four.
  • Full gap-file A/B (measured pre-rebase): all 433 test_gap_*.ts produce byte-identical stdout and exit code with and without the fix under the default configuration.

Unit-test teeth, re-verified three ways

cargo test --test scalar_replaced_slot_roots -p perry-codegen, with crates/perry-codegen/src reverted to d30f3bea8 (sanity-checked: 0 occurrences of root_scalar_replaced_slot / reserve_shadow_slot in the reverted tree):

  • fix reverted → the 4 positive tests FAIL, the 2 gate tests pass (they assert absence of rooting, which is also true pre-fix — that is why they need the third direction below);
  • gate deliberately coarsened (the expr_is_known_non_pointer_shadow_value early-return removed) → the 2 gate tests FAIL, and the differential frame-slot assertion FAILS with heap-field literal has 3 slots, the numeric-only control has 3;
  • correct code → all 6 pass.

Note the earlier teeth run in this thread reported all 6 passing against "reverted" source. That run was bogus: it reverted to origin/main, which by then already was this PR. Recording it because the failure mode — a teeth check that silently reverts to nothing — looks exactly like a test with teeth.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Replies to the CodeRabbit review

🔴 Major — frame_slot_count(&ir) > 0 has no teeth (scalar_replaced_slot_roots.rs:209-213)

Correct, and fixed. o is pointer-typed, so collect_pointer_typed_locals already reserves it a slot and the frame is non-empty with or without this change — the assertion certified nothing. It is now differential against a structurally identical numeric-only control (same local, same field count, same reads, no rooting):

assert!(frame_slot_count(&ir) > frame_slot_count(&control),);

Verified in both directions rather than trusting the passing run:

  • crates/perry-codegen/src reverted to the pre-merge parent d30f3bea8 → the test fails;
  • fix restored but the expr_is_known_non_pointer_shadow_value gate deliberately removed → it still fails, and specifically on this assertion: heap-field literal has 3 slots, the numeric-only control has 3. That is the interesting direction — the assertion is sensitive to the control, so it cannot pass by the frame merely being non-empty.

🔵 Nitpick — cover split() and anonymous-shape constructor stores

Split: added, as scalar_replaced_split_parts_are_bound, and also stated differentially. The string local is itself pointer-typed and binds its own slot in both compilers, so a bare bind_calls > 0 would have passed pre-fix; the control is the same string local without the split. It fails against d30f3bea8 and passes with the fix.

Anonymous-shape constructor stores: already covered end-to-end, which I had not made visible — thanks for pushing on it. --print-hir on the runtime test shows that a closed-shape literal never becomes Expr::Object at all:

Let { id: 11, name: "o", … init: Some(New { class_name: "__AnonShape_fb05d419737c7897",
  args: [Call { callee: FuncRef(1), … }, Call { callee: FuncRef(0), … }] }) }

test_gap_repsel_scalar_replaced_locals.ts contains 15 such nodes, so every object case in it — the churn-based runtime coverage this comment asks for — exercises the anonymous-shape arm, not the Expr::Object arm. The IR tests cover the complementary path: Expr::Object survives only for literals is_closed_shape rejects (duplicate keys, spread/computed/method props), which is the arm the unit tests construct directly.

I did not add a hand-built HIR fixture for the anonymous-shape arm. It needs a synthesized __AnonShape_* class plus the non_escaping_news / non_escaping_new_used_fields facts to line up, and a fixture that quietly misses the arm reads as coverage forever — the failure mode this campaign has already paid for twice today. The runtime test proves the arm; a fixture that might not reach it would add risk, not confidence.

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.

gc: scalar-replaced object/array locals holding heap values are not precise roots

1 participant