Skip to content

fix(hir): register the dynamic parent of a mixin-of-a-mixin (#9079) - #9567

Closed
proggeramlug wants to merge 2 commits into
mainfrom
fix/9079-dynamic-parent-chain
Closed

fix(hir): register the dynamic parent of a mixin-of-a-mixin (#9079)#9567
proggeramlug wants to merge 2 commits into
mainfrom
fix/9079-dynamic-parent-chain

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes #9079

const Mixed2 = mixin(Mixed) — a mixin applied to a previous mixin's result — SIGSEGVed as soon as anything derived from it was constructed. Node prints 4 for the issue's reproducer; Perry exited 139 after unbounded recursion.

Root cause

The HIR mixin fast path in crates/perry-hir/src/lower/stmt.rs synthesizes a real class for const M = mixinFn(Base), copying the mixin's class AST with the extends clause rewritten to the concrete base.

At the second level the base is Mixed, which by then is a lexical value binding (the first level ends with emit_class_expression_value_binding). lower_class_from_ast therefore takes its locally_shadowed arm and captures the parent as extends_expr — a dynamic parent — instead of a static class link. That part is correct.

What was missing is the other half. Unlike the sibling const X = class {…} path immediately above it in the same function, this arm bound the synthesized class without emitting the declaration-time RegisterClassParentDynamic. So the generated Mixed2_constructor called js_get_dynamic_parent_value for its class id with no registration to answer it. With an undefined parent, js_fetch_or_value_super fell back using the most-derived receiver (Deep), re-selected Mixed2, and recursed until the stack overflowed.

This is why the failure looked arbitrary: a single-level mixin(Root) extends a real class, so extends_expr stays None and no registration is needed — which is exactly the case #9073 fixed.

Fix

Capture lowered_class.extends_expr before push_class_dedup moves the class out, and push a RegisterClassParentDynamic into module.init in source order — after the parent's own value binding (the registration reads that local) and before this class's binding. Same placement the sibling class-expression path uses.

Emitted HIR for the reproducer:

[0] Let  { name: "Mixed",  init: ClassRef("Mixed") }
[1] Expr(RegisterClassParentDynamic { class_name: "Mixed2", parent_expr: LocalGet(0) })   <-- new
[2] Let  { name: "Mixed2", init: ClassRef("Mixed2") }
[3] Expr(RegisterClassParentDynamic { class_name: "Deep",   parent_expr: LocalGet(1) })

Mixed (level 1, static parent Root) still gets no registration.

Validation

All on Linux (x86-64), fresh build of this branch:

PERRY_NO_AUTO_OPTIMIZE=1 cargo build --profile perry-dev \
  -p perry -p perry-runtime-static -p perry-stdlib-static
  • Baseline reproduced with a binary built from this same tree before the patch: exit 139 (SIGSEGV); Node prints 4.
  • After: test-files/test_gap_9079_dynamic_parent_chain_own_ctor.ts is byte-identical to the pinned Node 26.5.1 oracle (diff -u clean) under both PERRY_NO_AUTO_OPTIMIZE=1 and the default auto-optimize pipeline, exit 0.
  • LLVM (--trace llvm): every js_get_dynamic_parent_value(i32 N) in the module now has a matching js_register_class_parent_dynamic(i32 N, …)zero orphans. Mixed2_constructor fetches id 9, and id 9 is registered.
  • cargo test -p perry-hir — all green (378 + 22 further test targets, 0 failures).
  • New lowering unit test lower::tests::mixin_parent_chain::mixin_of_a_mixin_registers_its_dynamic_parent_before_its_own_binding — confirmed to FAIL against the unpatched lowering (stashed the stmt.rs hunk, re-ran, red) and pass with it. It also asserts the negative: level 1 must not gain a dynamic parent.
  • Gap suite scripts/run_gap_tests.sh (--filter test_gap_, 675 tests, Node 26.5.1, npm ci oracle deps): see the run summary in the comments below.
  • cargo fmt --all --check and git diff --check clean. No version bump; Cargo.toml/Cargo.lock/CLAUDE.md untouched.

Regression fixture

test-files/test_gap_9079_dynamic_parent_chain_own_ctor.ts keeps the issue's reproducer verbatim and adds the assertions the issue explicitly left open:

  • inherited Root state and the mixin method reached through both synthesized levels;
  • instanceof across the whole chain (Deep/Mixed2/Mixed/Root);
  • a leaf with no own constructor over the same two-level chain (the issue asked whether the own constructor was required — it is not the only shape that must work);
  • the still-working one-level case, pinned so this registration cannot regress what fix(codegen): dynamic-parent own-ctor classes ran field initializers twice — pi's startup crash #9073 fixed;
  • a three-level chain built from three distinct mixins, so a dropped level shows up as a missing method rather than being masked by identical bodies at each level.

New unit-test module crates/perry-hir/src/lower/tests/mixin_parent_chain.rs — split out rather than appended to lower/tests.rs, which sits at 1,980 of the 2,000-line file cap.

https://claude.ai/code/session_01SNcEDcviLvFMta5oL7Zxig

Summary by CodeRabbit

  • Bug Fixes

    • Fixed crashes when applying a mixin to another mixin.
    • Preserved inherited fields, mixin methods, prototype chains, and instanceof behavior across multi-level mixin chains.
    • Ensured classes with explicit or implicit constructors work correctly in dynamic parent chains.
  • Tests

    • Added regression coverage for one-, two-, and three-level mixin chains, including prototype and inheritance behavior.

Ralph Küpper added 2 commits September 2, 2026 19:39
`const Mixed2 = mixin(Mixed)` — a mixin applied to a previous mixin's
RESULT — SIGSEGVed as soon as anything derived from it was constructed.
Node prints `4` for the issue's reproducer; Perry exited 139 after
unbounded recursion.

The HIR mixin fast path in `lower/stmt.rs` synthesizes a real class for
`const M = mixinFn(Base)`. At the second level the base is `Mixed`, a
lexical VALUE binding, so `lower_class_from_ast` takes its
locally-shadowed arm and captures the parent as `extends_expr` — a
dynamic parent — rather than a static class link. That is correct. What
was missing is the other half: unlike the sibling `const X = class {…}`
path immediately above it, this arm bound the synthesized class without
emitting the declaration-time `RegisterClassParentDynamic`. The
generated `Mixed2_constructor` therefore called
`js_get_dynamic_parent_value` for its class id with no registration to
answer it; with an undefined parent `js_fetch_or_value_super` fell back
to the most-derived receiver, re-selected `Mixed2`, and recursed until
the stack overflowed.

Emit the registration here too, in source order after the parent's own
value binding and before this class's — exactly where the sibling path
puts it. A single-level `mixin(Root)` extends a real class, keeps
`extends_expr` at `None`, and is unchanged: that is why one level
already worked (#9073) and two did not.

Verified on Linux (perrymaster) with a fresh
`PERRY_NO_AUTO_OPTIMIZE=1 cargo build --profile perry-dev -p perry
-p perry-runtime-static -p perry-stdlib-static`:

- Baseline binary built from this tree before the patch: exit 139.
- After: the gap fixture is byte-identical to the pinned Node 26.5.1
  oracle under both `PERRY_NO_AUTO_OPTIMIZE=1` and the default
  auto-optimize pipeline.
- LLVM for the fixture: every `js_get_dynamic_parent_value(i32 N)` in
  the module now has a matching `js_register_class_parent_dynamic(i32
  N, …)` — zero orphans; `Mixed2_constructor`'s id is among them.
- `cargo test -p perry-hir`: all green. The new lowering unit test was
  confirmed to FAIL against the unpatched lowering.

The gap fixture keeps the issue's reproducer verbatim and adds the
assertions it left open: inherited `Root` state and the mixin method
through both synthesized levels, `instanceof` across the whole chain, a
leaf with no own constructor, the still-working one-level case, and a
three-level chain built from three distinct mixins so a dropped level
shows up as a missing method rather than being masked by identical
bodies.

Closes #9079

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

coderabbitai Bot commented Sep 2, 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: Team

Run ID: 3fecda95-14dd-495b-b37b-8f50025299eb

📥 Commits

Reviewing files that changed from the base of the PR and between ed99c35 and d886a20.

📒 Files selected for processing (5)
  • changelog.d/9567-mixin-of-a-mixin-dynamic-parent.md
  • crates/perry-hir/src/lower/stmt.rs
  • crates/perry-hir/src/lower/tests.rs
  • crates/perry-hir/src/lower/tests/mixin_parent_chain.rs
  • test-files/test_gap_9079_dynamic_parent_chain_own_ctor.ts

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


📝 Walkthrough

Walkthrough

The HIR mixin lowering path now registers dynamic parents for synthesized classes in nested mixin chains. New lowering and runtime tests cover registration order, constructors, field initialization, method lookup, and instanceof across one-, two-, and three-level chains.

Changes

Dynamic Mixin Parent Chain

Layer / File(s) Summary
Register synthesized mixin parents
crates/perry-hir/src/lower/stmt.rs, changelog.d/9567-mixin-of-a-mixin-dynamic-parent.md
The mixin synthesis path emits RegisterClassParentDynamic after the parent binding and before the synthesized class binding when extends_expr is present.
Validate mixin parent chains
crates/perry-hir/src/lower/tests.rs, crates/perry-hir/src/lower/tests/mixin_parent_chain.rs, test-files/test_gap_9079_dynamic_parent_chain_own_ctor.ts
Tests verify dynamic registration order, static-parent behavior, constructors, field initialization, mixin methods, and instanceof across nested mixin chains.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to d886a

This change restores correct nested-mixin inheritance without changing public interfaces or deployment behavior; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 4 files. (1 skipped: 1… 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 HIR fix for registering the dynamic parent of a mixin-of-a-mixin, which is the primary change.
Description check ✅ Passed The description is comprehensive and covers the summary, root cause, fix, related issue, validation, and regression tests. It does not use the template headings or checklist format, but it provides th…
Linked Issues check ✅ Passed The changes address issue #9079 by registering dynamic parents for second-level mixins, preserving single-level behavior, and adding coverage for constructors and multi-level mixin chains.
Out of Scope Changes check ✅ Passed The implementation, lowering test, regression fixture, and changelog entry directly support the linked issue and stated objectives. No unrelated code changes are identified.
Full details: Description check

Explanation

The description is comprehensive and covers the summary, root cause, fix, related issue, validation, and regression tests. It does not use the template headings or checklist format, but it provides the required substantive information.

Full details: Docstring Coverage

Explanation

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

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/9079-dynamic-parent-chain

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 via merge train #9572 (rebase-merge, authorship preserved).

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Gap suite: green

Full serial run of scripts/run_gap_tests.sh on Linux x86-64, PERRY_SKIP_BUILD=1 against this branch's perry-dev build, Node oracle 26.5.1 (the .node-version pin), root deps installed with npm ci:

Parity Pass:   670
Parity Fail:   5
Compile Fail:  0
Crashed:       0
Skipped:       0
Parity Rate:   99.2%

Gap snapshot OK — 675 tests match test-parity/gap_snapshot.json (5 known non-passing).

The 5 are exactly the committed snapshot entries (2159, 2514, perfhooks_3088…, prop_plan_cache_invalidation, v8_2_3680plus) — no divergence in either direction, so no snapshot change is needed. The new fixture test_gap_9079_dynamic_parent_chain_own_ctor passes (test 156/675).

Note on a first, discarded run — a local environment fault, not a finding

An earlier run of the same suite reported 25 compile_fail "regressions", every one of them an ext-routed test (http/http2/net/ws/zlib/events/fetch). That was entirely my local setup, and the error text says so:

Error: runtime library does not match this Perry compiler:
  library build: v0.5.1520 (source 7b00a3d6e3a1)
  Perry build:   v0.5.1520 (source 612ee5dbd0f1)
  library: target/perry-auto-6a579de46287ff46/release/libperry_runtime.a

The gap harness flips auto-optimize back on for ext-routed tests, which links against the cached target/perry-auto-* archives. Those were stale against a compiler I had built from a dirty working tree (so it identified by source hash rather than by commit). Rebuilding perry from the committed tree made the pair coherent again; the run above is that rebuild. test_gap_class_expr_dynamic_parent_ctor — the one failure in that list that sits closest to this change — compiles in 2.6 s and is byte-identical to Node, and it contains no mixinFn(Base) call, so this diff cannot reach its lowering.

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.

Two levels of dynamic parent (mixin of a mixin) with an own constructor SIGSEGVs

1 participant