Skip to content

fix(codegen): never let a shadow-frame slot index escape the pushed frame (#7154) - #7184

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
jdalton:fix/7154-shadow-frame-slot-overflow
Aug 1, 2026
Merged

fix(codegen): never let a shadow-frame slot index escape the pushed frame (#7154)#7184
proggeramlug merged 1 commit into
PerryTS:mainfrom
jdalton:fix/7154-shadow-frame-slot-overflow

Conversation

@jdalton

@jdalton jdalton commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What

collect_pointer_typed_locals assigns shadow-frame slot indices with a bare out.insert(id, next_slot); next_slot += 1;. A local id that appears in more than one Stmt::Let — duplicate var declarations share a single HIR binding and lowering keeps a Let at each declaration site — replaces the map entry but still burns an index. Every caller (codegen/function.rs, codegen/closure.rs, codegen/entry.rs, codegen/helpers.rs) sizes the frame with map.len(), so a function with redeclarations pushes a frame smaller than the highest slot index it emits.

lodash's runInContext has 170+ var redeclarations: it pushes js_shadow_frame_enter(i32 598) and then binds slots up to 769. Both js_shadow_slot_bind and the #7088 inline store bounds-check the index and silently no-op when it is at or beyond the frame length — so slots 598–769 are live locals that are invisible to the precise-root moving minor. An evacuating collection at a loop back-edge poll relocates the referent, rewrites every root it knows about, and leaves the compiled local slot pointing into from-space. The mutator then re-injects that stale address through ordinary stores — exactly the mutator-reinjection mechanism established at the end of the #7154 trail.

Evidence

Fix

Assign at most one slot per id (entry().or_insert_with), restoring the invariant the frame sizing depends on: map.len() == slots handed out == max index + 1, with a debug_assert so it cannot silently regress. Re-binding the same slot from each duplicate declaration site is correct — the id names one alloca.

The regression test compiles a duplicate-var function and asserts every emitted js_shadow_slot_bind index (which the #7088 inline store mirrors on its fallback arm) is inside the pushed frame.

Verification

check result
minimal repro, main @ 6406ed66e (polls=1) red 4/4
minimal repro, this branch (polls=1, ±FORCE_EVACUATE) clean 6/6, correct output
sfw-registry --help, default arm clean 5/5 (before and after — polls off since #7161)
sfw-registry --help, polls=1, this branch still red — a second, independent polls-only offender remains (details on #7154); this PR does NOT close #7154
cargo test -p perry-codegen --no-fail-fast failing-target set identical to pure main (loop_safepoint_purity is red on main since #7161's default flip; native_proof_* targets are order-flaky on main — verified by isolated re-runs passing on both trees); the new test passes

Refs #7154. Keeps stopgap #7161 in place — the registry's polls-on arm is not clean yet.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where duplicate local declarations could allocate excess shadow-frame slots.
    • Ensured generated binding indexes remain within valid frame bounds.
    • Corrected slot assignment for parameters, declarations, and catch parameters.
  • Tests

    • Added regression coverage for duplicate declarations and pointer-local slot allocation.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Shadow-frame slot allocation now deduplicates repeated HIR local IDs across parameters, declarations, and catch parameters. A debug assertion checks frame sizing. A regression fixture verifies bounded indices and correct allocation for a later pointer local.

Changes

Shadow-slot deduplication

Layer / File(s) Summary
Deduplicated slot allocation
crates/perry-codegen/src/collectors/pointer_locals.rs
A shared allocator reuses slots for duplicate local IDs across parameters, declarations, and catch parameters. A debug assertion compares mapped slots with the frame-size counter.
Duplicate-declaration regression coverage
crates/perry-codegen/tests/shadow_slot_hygiene.rs, changelog.d/7184-shadow-frame-slot-overflow.md
The fixture and test cover duplicate var declarations, bounded binding indices, and the trailing pointer slot. The changelog records the correction and a remaining registry issue.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • PerryTS/perry issue 6995: It directly addresses duplicate-local shadow-slot allocation in pointer_locals.rs.
  • PerryTS/perry issue 6998: It also concerns shadow-slot and frame consistency in pointer_locals.rs, although it addresses a different defect.

Possibly related PRs

  • PerryTS/perry#6903: It also changes hoisted var redeclaration slot allocation, but uses a different storage mechanism and fix.

Suggested reviewers: proggeramlug, thehypnoo

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR fixes a related shadow-frame defect but does not address issue #7154's primary remembered-set and write-barrier requirements. Link a dedicated issue for the shadow-frame defect or clarify that #7154 explicitly includes this separate contributing defect.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the codegen fix that prevents shadow-frame slot indices from exceeding the pushed frame.
Description check ✅ Passed The description clearly explains the defect, fix, linked issue, and verification, but it does not follow the repository template headings or checklist.
Out of Scope Changes check ✅ Passed The code, regression test, and changelog entry all support the documented shadow-frame slot allocation fix and are not unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

…rame

Duplicate `var` declarations share one HIR local id but keep a Stmt::Let
per declaration site. collect_pointer_typed_locals burned a slot index for
every Let while the map kept one entry per id, and every caller sizes the
frame with map.len() — so functions with redeclarations pushed a frame
smaller than the highest index handed out (lodash's runInContext: frame 598,
binds up to 769). js_shadow_slot_bind and the PerryTS#7088 inline store both
bounds-check and silently no-op on an out-of-frame index, so those locals
were live but invisible to the precise-root moving minor: an evacuating
collection at a loop back-edge poll relocated the object and left the
compiled local slot pointing into from-space — the mutator then re-injected
the stale address through ordinary stores ("TypeError: value is not a
function" once called). This is the sfw-registry --help crash under
PERRY_GC_MOVING_LOOP_POLLS=1 traced in PerryTS#7154 (lldb: lodash.js:10751,
castRest read from an unrooted local).

Assign at most one slot per id (entry().or_insert_with), restoring the
invariant map.len() == slots handed out == max index + 1, and assert it.
Regression test compiles a duplicate-var function and asserts every emitted
slot index is inside the pushed frame.

Refs PerryTS#7154.
@jdalton
jdalton force-pushed the fix/7154-shadow-frame-slot-overflow branch from a9191ad to 9407445 Compare August 1, 2026 12:26
@proggeramlug
proggeramlug merged commit 73a9084 into PerryTS:main Aug 1, 2026
0 of 7 checks passed

@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.

🧹 Nitpick comments (1)
crates/perry-codegen/tests/shadow_slot_hygiene.rs (1)

689-705: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that the duplicate local uses slot 0.

The current assertions prove only that indices are bounded and that the trailing local uses slot 1. They do not prove that both id: 1 declarations reuse the first slot. A future allocator could alias both locals at slot 1 and still satisfy these assertions.

Proposed assertion
     assert!(
         fn_ir.contains("`@js_shadow_slot_bind`(i32 1, ptr %"),
         "trailing pointer local must be rooted at the deduped index; fn IR:\n{fn_ir}"
     );
+    assert!(
+        fn_ir.contains("`@js_shadow_slot_bind`(i32 0, ptr %"),
+        "duplicate local must use the first deduplicated slot; fn IR:\n{fn_ir}"
+    );
🤖 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/shadow_slot_hygiene.rs` around lines 689 - 705,
Extend the assertions in the shadow-slot hygiene test to verify that both
duplicate id: 1 locals bind to slot 0. Keep the existing bounds and
trailing-local slot-1 assertions, and inspect the parsed `@js_shadow_slot_bind`
entries so the duplicate declarations cannot both be incorrectly assigned slot
1.
🤖 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.

Nitpick comments:
In `@crates/perry-codegen/tests/shadow_slot_hygiene.rs`:
- Around line 689-705: Extend the assertions in the shadow-slot hygiene test to
verify that both duplicate id: 1 locals bind to slot 0. Keep the existing bounds
and trailing-local slot-1 assertions, and inspect the parsed
`@js_shadow_slot_bind` entries so the duplicate declarations cannot both be
incorrectly assigned slot 1.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ab45e265-3b66-47e1-8408-222d03f27286

📥 Commits

Reviewing files that changed from the base of the PR and between 9ab10d9 and 9407445.

📒 Files selected for processing (3)
  • changelog.d/7184-shadow-frame-slot-overflow.md
  • crates/perry-codegen/src/collectors/pointer_locals.rs
  • crates/perry-codegen/tests/shadow_slot_hygiene.rs

jdalton added a commit to jdalton/perry that referenced this pull request Aug 1, 2026
… after it

PerryTS#7184 fixed one instance of "a live GC value is invisible to the moving minor
because its root store silently no-ops": the store's slot index fell outside the
pushed shadow frame, so js_shadow_slot_bind bounds-checked it away. This is its
sibling — the store is emitted in-frame, but LATE. The invariant is that a
GC-managed value's root store must DOMINATE every subsequent site that can
trigger a collection; four lowerings broke it by keeping the value in a bare SSA
register across a call that allocates, which under PERRY_GC_MOVING_LOOP_POLLS=1
reaches a back-edge poll and an evacuating minor.

new C(...) is the load-bearing one. The instance was a raw register while the
constructor body ran. It is rooted inside the callee (the `this` parameter has a
shadow slot), so the minor does not free it — it MOVES it and rewrites the
callee's root, leaving the caller's register naming from-space.
js_gc_init_typed_shape_layout then installed the layout descriptor on the
abandoned copy and js_ctor_return_override published that dead address into the
caller's shadow slot: a *rooted* slot holding a dangling pointer, which is
exactly why PerryTS#7154's from-space scan only ever saw offenders one or more cycles
after the target died, with correct layout coverage. The instance is now
temp-rooted across the constructor and re-read afterwards, on both the
standalone-<Class>_constructor symbol path and the inline-ctor path. A class
with no constructor, no fields and no heritage runs no user code in that window
and keeps its previous IR exactly.

Three more sites of the same shape:

  * Expr::ObjectSpread — `{ ...a, k: f() }` allocated the object and then wrote
    every field through a register held across each part's lowering, with no
    rooting at all. Expr::Object has used RootedHandle for this since PerryTS#6951; the
    spread form never got it.
  * Expr::ClassExprFresh — same for the fresh class object built by a
    class-expression factory, across its static-field initializers, captured
    arguments, symbol statics and `static { … }` blocks.
  * property / element stores — `o.k = f()` evaluates the reference first and
    the value second (spec order), leaving the receiver in a register across
    `f()`. The slot it was loaded from is a root and gets rewritten; the
    register does not, so the store landed in from-space and the field never
    appeared on the object the program kept. This is PerryTS#7114 with a receiver
    instead of a string literal. temp_root::ReceiverGuard roots it only when the
    value expression can collect, so an inert RHS keeps its previous IR.

temp_root_scope_begin now takes the caller's extra reason to open a scope,
because `new C()` with no arguments still needs a marker to cut against.

Verified by test-files/test_gap_gc_new_instance_rooting.ts: wrong 9 times in 400
iterations at pure 73a9084 under PERRY_GC_MOVING_LOOP_POLLS=1 (3/3 runs,
deterministic), clean 5/5 by default, clean 10/10 with this fix. A codegen
regression test pins the def-use chain: the value js_ctor_return_override
publishes must be re-derived from the instance's temp root.

Also adds scripts/gc_root_dominance_check.py, the static checker that found
these — it builds each function's CFG from the emitted LLVM, computes real
Cooper/Harvey/Kennedy dominance, and reports every root store that does not
dominate a preceding collection point. Both shipped bugs of this class are
invisible to runtime GC probes, because at the moment of the collection there is
nothing for the collector to find. Over the 196-module sfw-registry corpus it
reported 234 violations before this change and 1 after.

sfw-registry --help under PERRY_GC_MOVING_LOOP_POLLS=1 is still red — at least
one more site of this class remains — so this does not close PerryTS#7154 and stopgap
PerryTS#7161 stays. Its default arm is unchanged (clean 5/5).

Refs PerryTS#7154, PerryTS#7184, PerryTS#7161, PerryTS#7114, PerryTS#6951.
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: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function'

2 participants