Skip to content

fix(codegen): don't scalar-replace an instance whose class chain reaches an unmodeled base (#6343)#6357

Merged
proggeramlug merged 1 commit into
mainfrom
fix/6343-scalar-runtime-installed-props
Jul 13, 2026
Merged

fix(codegen): don't scalar-replace an instance whose class chain reaches an unmodeled base (#6343)#6357
proggeramlug merged 1 commit into
mainfrom
fix/6343-scalar-runtime-installed-props

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Escape analysis promotes a non-escaping new C() to one alloca per DECLARED field and inlines the constructor stores into them. That model is faithful only when codegen can see the whole construction — and a native base cannot be seen.

EventEmitter and the node:stream classes install their method surface as own properties on the instance at subclass-init time (js_object_set_field_by_name(obj, "emit", <native closure>)), not on a prototype. A runtime-installed own property has no declared slot, so it was simply absent from the promoted set:

import { EventEmitter } from "node:events";
class X extends EventEmitter {
  a = 1;                       // a declared field is what makes it look promotable
}
const x = new X();
console.log("field:", x.a, "typeof emit:", typeof x.emit, "typeof on:", typeof x.on);
output
node 26 field: 1 typeof emit: function typeof on: function
main field: 1 typeof emit: undefined typeof on: undefined
this PR field: 1 typeof emit: function typeof on: function

Silent wrong answer, no error, no diagnostic. Closes #6343.

The trigger is that the instance never escapes. A method call on the receiver (x.on(…)) forces the heap path and hides the bug entirely — which is why this survived so long. The shape that bites is a bare property read sitting next to a field read, i.e. exactly typeof x.emit.

While writing the fixture I found the issue's "drop the field and the methods reappear" note is a red herring: class X extends EventEmitter {} with no field is equally broken when its only use is a bare read (the promoted set is then simply empty). What made the methods reappear in the original snippet was the method call, not the missing field.

The fix

class_chain_has_unmodeled_base (perry-codegen/src/collectors/this_as_value.rs) walks the extends chain and disqualifies a scalar-replacement candidate when the chain reaches a base whose construction is opaque to codegen:

  • native_extends — events / node:stream / Web Streams / async_hooks / ws. Resolved by walking the chain, so class Leaf extends Mid, class Mid extends EventEmitter is caught too, not just a literal extends EventEmitter.
  • extends <expr> (including a lexically shadowed heritage name) — an arbitrary runtime parent whose constructor can install anything.
  • a parent name that resolves to no visible class — a builtin (Error, Map, Set, Event, …) or an import whose stub never landed. Imported classes do land in class_table as stubs, so an ordinary cross-module class Local extends ImportedBase still resolves and stays on the fast path.

A cyclic chain fails closed. It is wired into collect_non_escaping_news as one more Pass-3 disqualifier, next to #313 (this-as-value) and #573 (builtin Error) — the same family, and the same shape as #5872/#6324's Pass-4 dispatch-stability guard.

This generalizes the #573 class_chain_extends_builtin_error check, which stays as-is: it is name-keyed and therefore also fires for a locally shadowed Error, which the chain walk would resolve to the user class and let through. Keeping both means the analysis only ever gets more conservative.

It did not need #6342's helper

#6342 adds native_instance_base_in_chain in lower_call/new_helpers.rs, which enumerates named native bases (EventEmitter, Map, Set, Event, CustomEvent) because it has to decide which init to emit. This pass only has to decide whether the base is opaque, which is a strictly weaker question with a name-free answer — "does the chain leave the set of classes codegen can see". So there's no dependency and no duplicated list: the two land independently and can merge in either order. (#6342's own writeup calls this bug out as pre-existing and orthogonal, which matches.)

The fast path is preserved — IR evidence

The point of a precise disqualifier is that the real perf win survives. Same two source files compiled by a pristine origin/main perry and by this branch's perry (identical libperry_runtime.a — this is a codegen-only change, verified by md5, so the archive cannot be a confound):

##### PLAIN CLASS  hot()  — must stay scalar-replaced #####
  baseline  heap-alloc: 0   method-dispatch: 0   body lines: 128
  fixed     heap-alloc: 0   method-dispatch: 0   body lines: 128
  -> hot() IR byte-identical across arms? YES

##### EventEmitter SUBCLASS  probe()  — must flip to the heap path #####
  baseline  heap-alloc: 0   body lines: 44     js_event_emitter_subclass_init: absent
  fixed     heap-alloc: 1   body lines: 284    js_event_emitter_subclass_init: present

The plain non-escaping class emits byte-identical IR on both sides — zero allocations, zero dispatch. The regression is localized exactly to the class that was miscompiled: on main its probe() never even called js_event_emitter_subclass_init, which is the bug in one line of IR.

scripts/run_issue_945_scalar_method_ir_guard.shPASS (both halves: the scalar fast path and the #5872 must-dispatch mirror). test-files/test_issue_945_scalar_method_guards.ts stays byte-identical to Node.

Validation

Fixturetest-files/test_gap_6343_scalar_native_base_surface.ts, byte-identical to node --experimental-strip-types. It fails on pristine main on four independent probes (X, Multi, R, W) and passes here. It covers EventEmitter, node:stream Readable + Writable, an indirect Leaf → Mid → EventEmitter chain, and an Error subclass, plus controls: a plain class and a pure user-class chain that must stay scalar-replaced, an explicit-super() subclass, and a live emitter (listener actually fires).

Unit tests — 7 new cases in collectors::this_as_value pin the chain walk: plain class and user-class chain accepted; direct native parent, indirect native parent, unresolvable parent name, dynamic extends <expr>, and a cyclic chain all rejected. cargo test -p perry-codegen --lib: 176 passed, 0 failed.

Gap-suite A/B, 298 tests, two genuinely distinct compilers. Baseline = a perry built from a worktree pinned at origin/main (be5ed2bfc) and snapshotted before the fix commit (be5ed2bfc; the branch has since been rebased onto current main, which added only an unrelated docs commit and #6340); both arms run against the same (unchanged, md5-identical) runtime archives, so the compiler is the only variable. md5(baseline perry) != md5(fixed perry), and the baseline binary demonstrably reproduces the bug while the fixed one does not — so the A/B is not vacuous.

295 identical / 3 changed → zero regressions.

test baseline this PR
test_gap_6343_scalar_native_base_surface FAIL PASS the new fixture
test_gap_console_methods FAIL FAIL run-to-run noise
test_gap_module_const_local_shadow FAIL FAIL run-to-run noise

The two unchanged-verdict tests are proven noise rather than build differences — re-running the baseline binary alone reproduces each:

  • test_gap_console_methods diverges only on a console.time wall-clock line (timer1: 0.010ms vs 0.009ms); three consecutive runs of the baseline binary print 0.001ms / 0.002ms / 0.002ms.
  • test_gap_module_const_local_shadow is already in test-parity/known_failures.json — it prints uninitialized memory, and three consecutive runs of the baseline binary yield x+1.116756386944e-311, x+2.613644130718e-311, x+2.771571180747e-311.

cargo fmt --all -- --check clean; scripts/check_file_size.sh clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability for classes extending native, dynamic, unresolved, or otherwise complex base classes.
    • Prevented inherited properties and methods from becoming unavailable in certain optimized object-handling scenarios.
    • Preserved expected behavior for event-driven classes, stream subclasses, error subclasses, and explicit constructor patterns.
  • Tests

    • Added coverage for native inheritance chains, dynamic inheritance, unresolved parents, cyclic chains, and fully modeled user-defined classes.

… unmodeled base (#6343)

Escape analysis promoted a non-escaping `new C()` to one alloca per DECLARED
field and inlined the constructor stores. That model is only faithful when
codegen can see the whole construction — and a native base cannot be seen.

`EventEmitter` and the `node:stream` classes install their method surface as
OWN PROPERTIES on the instance at subclass-init time
(`js_object_set_field_by_name(obj, "emit", <native closure>)`), not on a
prototype. A runtime-installed own property has no declared slot, so it was
simply absent from the promoted set:

    class X extends EventEmitter { a = 1 }
    const x = new X();
    x.a        // 1          (declared field — promoted)
    x.emit     // undefined  (installed own property — no slot)

Silent wrong answer, no error. A method call on the receiver forces the heap
path and hides it, so the shape that bites is a bare property read next to a
field — exactly what `typeof x.emit` does.

`class_chain_has_unmodeled_base` walks the `extends` chain and disqualifies a
candidate when the chain reaches a base whose construction is opaque:

  * `native_extends` — events / node:stream / Web Streams / async_hooks / ws;
    found by walking the CHAIN, so `class Leaf extends Mid extends
    EventEmitter` is caught too, not just a literal `extends EventEmitter`;
  * `extends <expr>` (incl. a lexically shadowed heritage name) — an arbitrary
    runtime parent whose constructor can install anything;
  * a parent name that resolves to no visible class — a builtin (`Error`,
    `Map`, `Set`, …) or an import whose stub never landed.

A cyclic chain fails closed. This is the third member of the family that
already holds #313 (`this`-as-value), #573 (builtin `Error`) and #5872
(dispatch stability), and it is precise for the same reason they are: a chain
of ordinary user classes is fully modeled, so the plain non-escaping class
keeps its scalar replacement and the #945 IR guard's zero-alloc / zero-dispatch
fast path is untouched.

Fixture `test_gap_6343_scalar_native_base_surface.ts` covers EventEmitter,
`Readable`, `Writable`, an indirect chain, and an Error subclass, with
controls for a plain class and a pure user-class chain.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Scalar replacement now detects unmodeled inheritance bases, including native, dynamic, unresolved, and cyclic chains, and marks affected allocations as escaped. Unit and TypeScript tests cover native surfaces and user-modeled control cases.

Changes

Unmodeled inheritance handling

Layer / File(s) Summary
Inheritance chain detection
crates/perry-codegen/src/collectors/this_as_value.rs
Adds class_chain_has_unmodeled_base, which rejects native, dynamic, unresolved, invisible, and cyclic inheritance chains.
Escape-analysis integration
crates/perry-codegen/src/collectors/escape_news.rs, crates/perry-codegen/src/collectors/mod.rs
Marks candidates with unmodeled bases as escaped and re-exports the new helper.
Native-surface regression coverage
test-files/test_gap_6343_scalar_native_base_surface.ts
Tests native EventEmitter and stream subclasses, indirect chains, listener behavior, and user-modeled scalar-replacement controls.

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

Possibly related issues

Possibly related PRs

  • PerryTS/perry#5705 — Provides related handling for dynamic or shadowed inheritance metadata used by the new chain analysis.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: preventing scalar replacement for instances whose class chain reaches an unmodeled base.
Description check ✅ Passed The description covers the summary, fix details, related issue, and validation, though it does not follow the template headings exactly.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/6343-scalar-runtime-installed-props

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.

@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/src/collectors/this_as_value.rs (1)

574-661: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the heritage_lexically_shadowed path.

The heritage_lexically_shadowed flag is checked at Line 188 alongside extends_expr, but no test exercises it. This flag guards a distinct scenario: the extends_name resolves to a visible user class in the map, but the actual parent is a shadowed runtime binding. Without this check, the walk would reach the user class and return false (fully modeled), incorrectly allowing scalar replacement. Removing the heritage_lexically_shadowed condition would not be caught by any existing test.

✅ Proposed test for `heritage_lexically_shadowed`
     /// A cyclic chain proves nothing, so it must fail closed (escape).
     #[test]
     fn unmodeled_base_fails_closed_on_cyclic_parent_chain() {
         let child = class("A", Some("B"));
         let parent = class("B", Some("A"));
         let mut classes = HashMap::new();
         classes.insert(child.name.clone(), &child);
         classes.insert(parent.name.clone(), &parent);

         assert!(class_chain_has_unmodeled_base(&child, &classes));
     }
+
+    /// `heritage_lexically_shadowed` — the extends name resolves to a visible
+    /// user class, but the actual parent is a shadowed runtime binding.
+    /// Must fail closed even though the name is in the `classes` map.
+    #[test]
+    fn unmodeled_base_rejects_lexically_shadowed_heritage() {
+        let mut child = class("X", Some("EventEmitter"));
+        child.heritage_lexically_shadowed = true;
+        // Insert a user class named "EventEmitter" so the name resolves —
+        // without the heritage_lexically_shadowed check, the walk would
+        // reach this root class and return false (incorrectly modeled).
+        let emitter = class("EventEmitter", None);
+        let mut classes = HashMap::new();
+        classes.insert(child.name.clone(), &child);
+        classes.insert(emitter.name.clone(), &emitter);
+
+        assert!(class_chain_has_unmodeled_base(&child, &classes));
+    }
 }
🤖 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/src/collectors/this_as_value.rs` around lines 574 - 661,
Add a focused unit test alongside the existing unmodeled-base tests that creates
a child whose extends_name resolves to a visible user class while setting
heritage_lexically_shadowed, then asserts class_chain_has_unmodeled_base returns
true. Ensure the setup distinguishes the shadowed runtime parent from the
modeled class so removing the flag check would fail the test.
🤖 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/src/collectors/this_as_value.rs`:
- Around line 574-661: Add a focused unit test alongside the existing
unmodeled-base tests that creates a child whose extends_name resolves to a
visible user class while setting heritage_lexically_shadowed, then asserts
class_chain_has_unmodeled_base returns true. Ensure the setup distinguishes the
shadowed runtime parent from the modeled class so removing the flag check would
fail the test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 38ba325f-181d-4694-bacf-8894b2d38ec6

📥 Commits

Reviewing files that changed from the base of the PR and between 448970d and 7c4c76e.

📒 Files selected for processing (4)
  • crates/perry-codegen/src/collectors/escape_news.rs
  • crates/perry-codegen/src/collectors/mod.rs
  • crates/perry-codegen/src/collectors/this_as_value.rs
  • test-files/test_gap_6343_scalar_native_base_surface.ts

@proggeramlug proggeramlug merged commit 0eab057 into main Jul 13, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/6343-scalar-runtime-installed-props branch July 13, 2026 12:17
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.

codegen: scalar replacement drops runtime-installed own properties — class X extends EventEmitter { a = 1 } reads typeof x.emit as undefined

1 participant