Skip to content

fix(codegen): stage no ancestor field initializers a delegated constructor installs itself (#11120) - #11129

Closed
proggeramlug wants to merge 2 commits into
mainfrom
fix/11120-ancestor-field-init-staged-once
Closed

proggeramlug wants to merge 2 commits into
mainfrom
fix/11120-ancestor-field-init-staged-once

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #11120

Root cause

Before a constructor body runs, a construction stages ancestor field initializers (AncestorsOnly / UpToInclusive). It then hands the parent part to a parent constructor. On three codegen paths that parent constructor installs the same ancestors again. Public initializers then run twice, and the side effects double silently. A #private field throws Cannot initialize a private field twice on the same object.

  1. super(...spread): the argument count is dynamic, so the parent can't be inlined. The call lowers to js_super_construct_apply, which runs the parent's whole registered standalone constructor. That constructor already installs every field from the root through the parent, but the root was also staged up front. This is the redis case. A CommonJS module body is a function, so EmptyAwareSinglyLinkedList captures events_1 and gets a synthesized constructor(...args) { super(...args) }. The events part of the issue title is incidental: any captured binding triggers it, and so does an explicit spread super. The fix: ctor_super_reruns_parent_ctor (new, field_init.rs) mirrors the SuperCallSpread arm selection. It follows a plain super() into the inlined ancestor, so F { constructor() { super() } } over a spread E is covered too. When it answers yes, nothing above the leaf is staged.
  2. UpToInclusive(stop_at) is used when a no-own-ctor class inlines an inherited constructor body. It staged the whole prefix, stop_at included. That body's own super() then applies the intermediates and SelfOnly(stop_at) again. So a plain class F extends E {}, over class E extends S { #b = 1; constructor() { super(); } }, threw on new F(). It now stages the same thing stop_at's constructor stages when stop_at itself is constructed.
  3. The standalone constructor of a no-own-ctor class (the dynamic new <classValue>() path) staged the root. It then called the local ancestor's constructor symbol, which staged the root again. Afterwards it applied only SelfOnly, so constructor-free intermediates were dropped. For example, in S <- E(ctor) <- M <- F, new (F as any)() left m undefined. When the synthesized super calls a local ancestor's constructor symbol, the standalone constructor now stages nothing up front and applies BetweenExclusiveTo(ancestor) afterwards.

This does not depend on #11122. The issue repro fails on origin/main 784ed8e and passes with this change, on main alone.

Tests

  • New test-files/test_gap_11120_ancestor_field_init_staged_once.ts covers the capture-synthesized constructor, an explicit spread super, inherited constructor bodies (static and dynamic new), a spread-super class reached as an inherited constructor, a captured spread-super class, and #x in o brand checks. It counts initializer runs.
    • Fails on the main build: Cannot initialize a private field twice on the same object.
    • Passes with the fix, byte-identical to Node 26.5.1 (/opt/node-v26.5.1-linux-x64).
  • The issue's verbatim repro (require("events"), auto-optimize path) prints A 1 / B 0 with the fix, the same as Node. main throws.
  • 21 more hand-reduced variants (capture, spread, inherited, dynamic-construct, public-field side-effect counts) all match Node with the fix. The exceptions are those hitting Base-class method can't read its #private field on a subclass instance when both classes are function-local #11127 / User method named push on an instance from new <any class value> loses its effects and returns the wrong value #11128 below, which fail identically on main.
  • Compile-and-diff A/B over 181 related tests: test-files/*.ts with extends plus a private field, spread super, constructor or = new. Both arms were perry-dev builds, PERRY_NO_AUTO_OPTIMIZE=1.
    • 180 are byte-identical between main and the fix.
    • 1 differs: the new test, which now matches Node.
    • Matching Node: 172 on main, 173 with the fix.
    • I used this loop instead of run_parity_tests.sh because port 17891 was held by another sweep on the host.
  • cargo test -p perry-codegen: 2190 passed, 0 failed, 6 ignored (40 suites), no warnings. This ran on macOS, debug profile.
  • cargo fmt --all -- --check: ok. scripts/check_file_size.sh: ok.
  • SKIP_COMPILE_GATES=1 scripts/run_lint_gates.sh: 87 of 88 script gates passed. The failure is cargo xwin check, because cargo-xwin is not installed on the Linux host. The compile tier was not run. git diff --stat was clean afterwards.

Not run

  • Full gap sweep, and the harness itself (port collision, see above).
  • cargo check --workspace --all-targets -D warnings: only perry-codegen is touched, and its test build is warning-free.
  • Instruction-count A/B: this is a codegen change that removes duplicated initializer work on the affected paths and leaves other paths byte-identical. No measurement was taken.

Found along the way (separate, pre-existing, unchanged by this PR)

Summary by CodeRabbit

  • Bug Fixes
    • Fixed an issue where class field initializers could run more than once during object construction. Initializers, including those for private fields, now run once across supported inheritance and constructor paths, preventing duplicate initialization and related side effects.

…uctor installs itself

A construction stages ancestor field initializers before a constructor body
runs. It then hands the parent part to a parent constructor. On three paths
that parent constructor installed the same ancestors again. Public
initializers ran twice (side effects doubled), and a #private field threw
"Cannot initialize a private field twice on the same object":

- super(...spread) lowers to js_super_construct_apply, which runs the
  parent's whole standalone constructor. The root was also staged up front.
  This is the redis@6.1.0 createClient({ socket }) failure: a CommonJS module
  body is a function, so its classes capture and get a synthesized
  super(...args) constructor.
- An inherited constructor body inlined for a no-own-ctor class
  (UpToInclusive) staged the whole prefix, including the inherited class
  itself. That body's own super() applies the intermediates and the class
  itself again. Plain class F extends E {} over a constructor-owning derived
  E with a #field threw on new F().
- The standalone constructor of a no-own-ctor class staged the root, then
  called a local ancestor's constructor symbol, which staged it again. It
  also applied only SelfOnly afterwards, which dropped constructor-free
  intermediates.

Fixes #11120
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: dea26158-0f67-420d-83c6-ed516768c8d6

📥 Commits

Reviewing files that changed from the base of the PR and between 784ed8e and 1f9c509.

📒 Files selected for processing (5)
  • changelog.d/11129-ancestor-field-init-staged-once.md
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/lower_call/field_init.rs
  • crates/perry-codegen/src/lower_call/new_helpers.rs
  • test-files/test_gap_11120_ancestor_field_init_staged_once.ts

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


📝 Walkthrough

Walkthrough

The code generator now adjusts ancestor field initialization across constructor paths, including spread super(...) calls and synthesized constructors. A new regression test checks initializer counts and private-field brands across several inheritance shapes.

Changes

Ancestor Field Initialization

Layer / File(s) Summary
Super-call analysis and ancestor staging
crates/perry-codegen/src/lower_call/field_init.rs, crates/perry-codegen/src/lower_call/new_helpers.rs
The compiler identifies spread and plain super() calls, detects when a spread call reruns a local parent constructor, and adjusts which ancestor fields are staged before constructor bodies.
Synthesized-super field selection and regression coverage
crates/perry-codegen/src/codegen/method.rs, test-files/test_gap_11120_ancestor_field_init_staged_once.ts, changelog.d/11129-ancestor-field-init-staged-once.md
Synthesized-super handling selects field initialization based on the effective parent constructor. The regression test checks initializer counts and private-field brands across captured, spread-super, inherited, and dynamic-class paths. The changelog describes the affected cases.

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

Merge Risk: ⚪ Minimal · up to 1f9c5

This change stops parent-class field initializers from running twice when a subclass reaches its parent through a spread super(...) call or an inherited constructor. That double run is what broke redis client creation with a duplicate private-field error. No concrete defects remain open from the review, and a new regression test covers the reported inheritance shapes, so the change appears ready to merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.23% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 4 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the code-generation fix and the prevention of duplicate ancestor field initialization in delegated constructors.
Description check ✅ Passed The description provides a clear root cause, explains the affected constructor paths, references issue #11120, documents the regression test, reports test results, and lists checks not run. It does no…
Linked Issues check ✅ Passed Issue [#11120] requires the duplicate ancestor initialization to stop, including the events field initializer case with a private-field parent. The PR changes constructor and field-initializer lower…
Out of Scope Changes check ✅ Passed The changed code is limited to constructor field-initialization lowering and supporting super-form analysis. The new test directly covers issue [#11120]. The changelog entry documents the same fix and…
Full details: Docstring Coverage

Explanation

Docstring coverage is 69.23% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 4 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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

Landed on main in merge train 270 (#11132, v0.5.1653). The train rebase gives commits new SHAs, so GitHub cannot close this automatically.

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.

Subclass field initializer 'new events_1()' with a private-field parent throws 'Cannot initialize a private field twice' (blocks redis createClient)

2 participants