Skip to content

fix(codegen): root pointer locals of constructor bodies inlined into other frames - #9102

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9081-three-render-target-gc
Aug 29, 2026
Merged

fix(codegen): root pointer locals of constructor bodies inlined into other frames#9102
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9081-three-render-target-gc

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #9081

Root cause

Codegen splices constructor bodies inline into other functions at five sites: the super(...) parent-body inline in a derived constructor (expr/this_super_call.rs), the new-site own-ctor and inherited-ctor inlines (lower_call/new.rs), and let_stmt's scalar-ctor variants. The enclosing function's shadow-slot map is computed by collect_pointer_typed_locals over that function's own params and body, so a spliced body's pointer locals — and its ctor params, bound by bind_inline_constructor_params — landed in plain entry allocas that are neither shadow slots nor temp roots. The collector never rewrites them, and a moving minor between the local's store and a later use leaves it holding the pre-move address (the CLAUDE.md "root-store dominance" class, #7207 shape, introduced by body splicing).

That is exactly how three.js died under compilePackages with a small nursery: RenderTarget's ctor body, spliced by the super() inline into the standalone WebGLRenderTarget_constructor (which the entry module calls as a symbol), holds const texture = new Texture(image) across this.textures = [] and the attachment loop. The from-space quarantine (PERRY_GC_PROTECT_FROMSPACE=1, depth 800) pinpointed the stale header deref inside that constructor; the emitted IR shows the spliced texture alloca has no js_shadow_slot_bind, while the standalone RenderTarget_constructor — whose own compile pass saw the body — roots the same local at slot 4. Downstream, Texture.copy() reads source.mipmaps off the retired address as undefinedTypeError: Cannot read properties of undefined (reading 'slice').

Fix

expr/shadow_slot.rs gains root_inlined_ctor_pointer_locals, called at all five splice sites before lowering the spliced body. It runs the same pointer-locals collector over the spliced params+body and extends the frame via reserve_shadow_slot, which grows the already-emitted frame in place on both root backends (native stack maps and shadow frames) — matching the bug reproducing under both PERRY_RS4GC arms. Let/assignment sites then mirror stores through the ordinary bind path; an id already bound in ctx.locals (a spliced ctor param) is bound immediately, and re-bound on a repeated inline of the same constructor so the slot tracks the newest alloca. Local ids are module-unique (one counter per lowering context), so extending the map never aliases an enclosing local, and a nested super() chain recurses through the same site naturally.

is_global_this_value moved from let_stmt.rs to let_stmt_facts.rs (pure relocation) to stay under the 2000-line cap.

Regression test

test-files/test_gap_gc_inlined_ctor_body_locals_rooting.ts, registered in the gc-repsel corpus. Its parity-env runs the seeded every-poll evacuating schedule with the from-space quarantine armed, and its stamp discriminator makes a stale spliced local observable by value after a single move (write through the collector-rewritten instance field, read back through the spliced local). Four arms: the dynamic-construct route that replays the standalone derived ctor (the exact three.js configuration), direct new of the derived/default/base classes.

Validation (on the issue's reproduction host)

  • New gap test on the unfixed compiler: SIGSEGV at minor #0 through the harness (CRASHED), quarantine naming the stale spliced-local deref. Fixed: byte-exact with the oracle under default env and parity-env, on both PERRY_RS4GC backends.
  • Original three.js repro (new WebGLRenderTarget(), three 0.180.0 via compilePackages): passes on default heap, forced 1 MiB nursery, forced nursery + quarantine (depth 800), and the PERRY_RS4GC=0 build. PERRY_GC_DIAG confirms 2 copying collections, so the stress is live, not vacuous.
  • gc_repsel_matrix.sh --arms pr on the new test: 7/7 cells byte-exact vs pinned node 26.5.1; the moving arms (evac_minor, force_verify) are live (3,469 objects moved).
  • Full test_gap_ sweep A/B on the same host, same oracle: failure lists byte-identical between the unfixed and fixed compilers except the new test (crash → pass). The pre-existing deltas on that host are its ext-feature link gaps (http2/net) and known snapshot entries.
  • cargo test -p perry-codegen: 1347 passed. scripts/run_lint_gates.sh: green except the known worktree-only cargo fmt --all manifest quirk (changed files are rustfmt-clean); the test-registration gate passes with the new corpus entry.

https://claude.ai/code/session_0131AxDes1mwPJJ343CxS4oX

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue that could cause object references in inlined constructors to become invalid during garbage collection.
    • Improved reliability for object creation across direct, inherited, and dynamic constructor paths.
  • Tests

    • Added regression coverage for garbage collection during constructor execution and object allocation.

Ralph Küpper added 2 commits August 29, 2026 23:54
…other frames

A constructor body spliced inline into another function — the super()
parent-body inline in a derived constructor, the new-site own/inherited
ctor inlines, and let_stmt's scalar-ctor variants — declared its locals
into plain entry allocas: the enclosing function's shadow-slot map is
computed by collect_pointer_typed_locals over that function's OWN params
and body, so the spliced body's pointer locals (and its bound ctor
params) were invisible to the collector. A moving minor between a
local's store and a later use left it holding the pre-move address.

This is issue PerryTS#9081's three.js failure: RenderTarget's ctor body,
spliced into WebGLRenderTarget_constructor by the super() inline, holds
`const texture = new Texture(image)` across `this.textures = []` and
the attachment loop. Under a 1 MiB nursery the texture local went
stale, and Texture.copy() read `source.mipmaps` as undefined ("Cannot
read properties of undefined (reading 'slice')"). The from-space
quarantine pinpointed the stale header deref inside the constructor;
the emitted IR showed the spliced texture alloca had no
js_shadow_slot_bind while the standalone RenderTarget_constructor
(whose own compile pass saw the body) rooted it at slot 4.

Fix: expr/shadow_slot.rs gains root_inlined_ctor_pointer_locals, called
at all five splice sites before lowering the spliced body. It runs the
same pointer-locals collector over the spliced params+body and extends
the frame through reserve_shadow_slot, which grows the already-emitted
frame in place on both root backends (native stack maps and shadow
frames) — matching the bug reproducing under both PERRY_RS4GC arms.
Let/assignment sites then mirror stores through the ordinary bind path;
an id already bound in ctx.locals (a ctor param) is bound immediately,
and rebound on a repeated inline of the same constructor so the slot
tracks the newest alloca. Local ids are module-unique, so extending the
map never aliases an enclosing local.

Also moves is_global_this_value from let_stmt.rs to let_stmt_facts.rs
(pure relocation) to stay under the 2000-line cap.

Regression test: test_gap_gc_inlined_ctor_body_locals_rooting.ts
(registered in the gc-repsel corpus). Its parity-env runs the seeded
every-poll evacuating schedule with the from-space quarantine armed: on
the unfixed compiler it SIGSEGVs at minor #0 (stale spliced-local
deref); fixed, it is byte-exact with the oracle. The write-after-move
stamp discriminator also catches the bug by value when quarantine pages
were recycled. Verified against the original three.js reproduction on
the remote host: default heap, forced 1 MiB nursery, quarantine, and
the PERRY_RS4GC=0 arm all pass; PERRY_GC_DIAG confirms 2 copying minors
so the stress is live.

Fixes PerryTS#9081

Claude-Session: https://claude.ai/code/session_0131AxDes1mwPJJ343CxS4oX
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cbb25811-d96d-4e8b-bd90-05727698800c

📥 Commits

Reviewing files that changed from the base of the PR and between db6df04 and 96062e5.

📒 Files selected for processing (9)
  • changelog.d/9102-inlined-ctor-body-locals-rooting.md
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/shadow_slot.rs
  • crates/perry-codegen/src/expr/this_super_call.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/stmt/let_stmt.rs
  • crates/perry-codegen/src/stmt/let_stmt_facts.rs
  • test-files/test_gap_gc_inlined_ctor_body_locals_rooting.ts
  • test-parity/gc_repsel_corpus.txt

Included review availability: Your plan provides up to 8 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

The change adds shadow-slot rooting for pointer locals and parameters from inlined constructor bodies. It wires the helper into five constructor-splicing paths, relocates is_global_this_value, and adds moving-GC regression coverage.

Changes

Inline Constructor GC Rooting

Layer / File(s) Summary
Pointer-local shadow-slot helper
crates/perry-codegen/src/expr/shadow_slot.rs, crates/perry-codegen/src/expr/mod.rs
The new helper collects pointer-typed constructor locals, reserves shadow slots, and binds existing locals when precise root analysis is enabled.
Constructor lowering integration
crates/perry-codegen/src/expr/this_super_call.rs, crates/perry-codegen/src/lower_call/new.rs, crates/perry-codegen/src/stmt/let_stmt.rs, crates/perry-codegen/src/stmt/let_stmt_facts.rs
Five inlined-constructor paths root parameters and body locals before lowering. is_global_this_value moves to let_stmt_facts.rs without behavior changes.
Moving-GC regression witness
test-files/test_gap_gc_inlined_ctor_body_locals_rooting.ts, test-parity/gc_repsel_corpus.txt, changelog.d/9102-inlined-ctor-body-locals-rooting.md
The test forces evacuation, covers four constructor paths, validates pointer state, and enters the GC representation-selection corpus. The changelog records the fix.

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

Merge Risk: ⚪ Minimal · up to 96062

The PR roots pointer locals in inlined constructor bodies to prevent stale references after moving garbage collection. No actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: thehypnoo, jdalton

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The implementation addresses the core issue by rooting pointer locals and constructor parameters at all five splice sites, and the regression test verifies moving-GC behavior. However, the linked issu… Add or reference an automated repository regression that compiles Three.js 0.180.0 through compilePackages and executes new WebGLRenderTarget() with a forced small scavenging nursery, while asserting successful output and confirming that ev…
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 7 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: rooting pointer locals from constructor bodies inlined into other frames.
Description check ✅ Passed The description provides detailed root-cause, fix, regression-test, linked-issue, and validation information. It does not use every template heading or include the checklist, but it is substantially c…
Out of Scope Changes check ✅ Passed The changes remain within scope. The helper relocation, constructor-rooting updates, changelog entry, regression test, and corpus registration all support the linked GC corruption fix.
Full details: Description check

Explanation

The description provides detailed root-cause, fix, regression-test, linked-issue, and validation information. It does not use every template heading or include the checklist, but it is substantially complete.

Full details: Linked Issues check

Explanation

The implementation addresses the core issue by rooting pointer locals and constructor parameters at all five splice sites, and the regression test verifies moving-GC behavior. However, the linked issue specifically requests a repository regression using compilePackages with Three.js 0.180.0 and new WebGLRenderTarget() under a forced small nursery; the provided test is a focused constructor-codegen test instead.

Resolution

Add or reference an automated repository regression that compiles Three.js 0.180.0 through compilePackages and executes new WebGLRenderTarget() with a forced small scavenging nursery, while asserting successful output and confirming that evacuation occurred.

Full details: Docstring Coverage

Explanation

Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 7 files. (2 skipped: 2 unsupported.)

  • Fix all pre-merge checks with AI
✨ 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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged. This is the highest-consequence class of bug in the codebase and the diagnosis is exactly right, so I validated it with both instruments rather than just the test.

The root cause statement is the valuable part:

The enclosing function's shadow_slot_map was computed by collect_pointer_typed_locals over that function's OWN params and body; an inlined constructor body's locals are invisible to it.

That is the third shape in CLAUDE.md's root-store-dominance list — "the value lives in a plain alloca_entry that is neither a shadow slot nor a temp root" — and the reason it survived is that the analysis and the splice are correct individually; only their composition is wrong. Extending the map through reserve_shadow_slot, which grows the already-emitted frame in place on both root backends, is the right repair, and sorting the ids before assigning slots so indices don't depend on HashMap iteration order is the kind of detail that would otherwise produce a nondeterministic build.

The behavioural A/B, under the knobs the test itself declares (PERRY_GC_SCHEDULE_SEED=9081 RATE=1 ALLOC_KB=0 FORCE_EVACUATE=1 VERIFY_EVACUATION=1 PROTECT_FROMSPACE=1):

plain under stress
main matches node — bug fully latent rc=138, from-space fault
this PR matches node matches node, 1541 copying minors, 27,708 objects moved

Main's failure is reported by the quarantine in as many words:

This address is RETIRED FROM-SPACE. The evacuating minor moved or
freed the object here and the holder kept the pre-collection address.

Which is the point worth underlining: without GC pressure both arms print identical correct output. A reviewer running this test plainly would have concluded there was nothing to fix. The parity-env header carrying the knobs is what makes the test able to fail at all.

The static instrument agrees independently. gc_root_dominance_check.py --unrooted-allocas over the emitted IR:

unrooted-alloca violations
main 7 — 6 in perry_fn_gap_ts__runDefaultCtor, 1 in gap_ts__DerivedOwnCtor_constructor
this PR 0, with 46 gc-capable allocas seen, no IMMOVABLE_SOURCES suppressions

The 46 is what makes the zero meaningful — the checker had subjects rather than being blind to the file. (The root-dominance check proper reports 0 root stores on this fixture and says so rather than returning a green; that is the tool being honest about not applying here, not evidence.)

The three.js provenance in the header — RenderTarget's const texture = new Texture(image) going stale by texture.clone(), surfacing as "Cannot read properties of undefined (reading 'slice')" in Texture.copy() — is worth keeping. It is the canonical presentation of this class: a TypeError about an unrelated property, several frames and one collection away from the actual defect.

Covering all four splice sites (the super(...) parent-body inline, both new-site inlines, and let_stmt's scalar-ctor variants) rather than only the one that bit three.js is the right scope, and the dynamic arm routing through js_new_function_construct to hit the standalone derived constructor is a nice touch — that is the configuration that actually reproduced in the wild.

Validation: codegen 1347 passed, runtime 2819 (RUST_TEST_THREADS=1), perry --bins 1066, fmt clean, run_lint_gates.sh all 60 gates passed; 2 CI-only skipped.

@proggeramlug
proggeramlug merged commit 6ad3929 into PerryTS:main Aug 29, 2026
17 of 20 checks passed
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.

compilePackages: moving GC corrupts Three WebGLRenderTarget construction

1 participant