Skip to content

Commit 0eab057

Browse files
proggeramlugRalph Küpper
andauthored
fix(codegen): don't scalar-replace an instance whose chain reaches an unmodeled base (#6343) (#6357)
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. Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
1 parent 4d858ae commit 0eab057

4 files changed

Lines changed: 351 additions & 1 deletion

File tree

crates/perry-codegen/src/collectors/escape_news.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@ pub fn collect_non_escaping_news(
4949
else if class_chain_extends_builtin_error(class, classes) {
5050
escaped.insert(*id);
5151
}
52+
// Issue #6343: the class chain reaches a base whose construction
53+
// codegen cannot see — a NATIVE base that stamps its method
54+
// surface onto the instance as own properties (`extends
55+
// EventEmitter`, `extends Readable`, …), a dynamic `extends
56+
// <expr>` parent, or a parent name that resolves to no visible
57+
// class. Scalar replacement promotes only the DECLARED fields of
58+
// the chain, so a runtime-installed own property has no slot in
59+
// the promoted set: `class X extends EventEmitter { a = 1 }` read
60+
// `x.a` correctly but `typeof x.emit` as `undefined`. The heap
61+
// path runs the real subclass-init and looks the property up on
62+
// the object. Same family as the #573 check above and the #5872
63+
// dispatch-stability pass below — a chain of ordinary user classes
64+
// is fully modeled and stays scalar-replaced.
65+
else if class_chain_has_unmodeled_base(class, classes) {
66+
escaped.insert(*id);
67+
}
5268
}
5369
}
5470

crates/perry-codegen/src/collectors/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,6 @@ pub(crate) use scalar_methods::simple_scalar_method_summary;
8383
pub(crate) use shadow_slots::{
8484
collect_declared_shadow_slots_in_stmts, collect_shadow_slot_clear_points,
8585
};
86-
pub(crate) use this_as_value::{class_chain_extends_builtin_error, class_uses_this_as_value};
86+
pub(crate) use this_as_value::{
87+
class_chain_extends_builtin_error, class_chain_has_unmodeled_base, class_uses_this_as_value,
88+
};

crates/perry-codegen/src/collectors/this_as_value.rs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,79 @@ pub fn class_chain_extends_builtin_error(
128128
false
129129
}
130130

131+
/// Issue #6343: walk the class's `extends` chain and report whether it reaches
132+
/// a base whose *construction* codegen cannot see.
133+
///
134+
/// Scalar replacement models an instance as exactly the set of DECLARED fields
135+
/// on its chain — one alloca per field name — and inlines the constructor
136+
/// bodies that fill them. That is only faithful when the whole chain is
137+
/// visible. A base that isn't contributes instance state the promoted set does
138+
/// not model, and every way that happens is silent:
139+
///
140+
/// * a **native** base — `class X extends EventEmitter`, `extends Readable`,
141+
/// … — installs its method surface as OWN PROPERTIES on the instance at
142+
/// subclass-init time (`js_object_set_field_by_name(obj, "emit", <native
143+
/// closure>)`) rather than on a prototype. `emit` has no declared slot, so
144+
/// the promoted set has no slot for it and `x.emit` reads back
145+
/// `undefined` (#6343: `class X extends EventEmitter { a = 1 }` printed
146+
/// `typeof x.emit === "undefined"` while `x.a` was correct).
147+
/// * a **dynamic** base — `extends <expr>`, including a lexically shadowed
148+
/// heritage name — runs an arbitrary constructor that can install
149+
/// anything.
150+
/// * a parent NAME that resolves to no visible class: a builtin (`Error`,
151+
/// `Map`, `Set`, `Event`, …) or a base whose declaration never reached
152+
/// this module. Its construction happens in the runtime, not in code
153+
/// codegen can inline.
154+
///
155+
/// The only sound answer for all three is to keep the instance on the heap,
156+
/// where the real init runs and property lookup goes through the object.
157+
///
158+
/// This is deliberately a chain property, not a name test: it must hop user
159+
/// classes (`class Leaf extends Mid`, `class Mid extends EventEmitter`) and it
160+
/// must NOT fire for a chain that bottoms out in an ordinary user class — that
161+
/// instance is fully modeled and keeping it scalar-replaced is a real win
162+
/// (guarded by `scripts/run_issue_945_scalar_method_ir_guard.sh`).
163+
///
164+
/// Generalizes [`class_chain_extends_builtin_error`] (#573), which stays as-is
165+
/// because it is name-keyed and therefore also fires for a *locally shadowed*
166+
/// `Error` — this walk resolves such a shadow to the user class and would let
167+
/// it through.
168+
pub fn class_chain_has_unmodeled_base(
169+
class: &perry_hir::Class,
170+
classes: &std::collections::HashMap<String, &perry_hir::Class>,
171+
) -> bool {
172+
let mut current = class;
173+
let mut seen: HashSet<String> = HashSet::new();
174+
loop {
175+
// A cycle (or a chain deep enough to look like one) means the walk
176+
// can't prove anything. Fail closed: keep the instance on the heap.
177+
if !seen.insert(current.name.clone()) || seen.len() > 64 {
178+
return true;
179+
}
180+
// `native_extends` is the (module, class) tag for a base whose
181+
// subclass-init shim stamps a surface onto `this` at construction —
182+
// events, node:stream, the Web Streams bases, async_hooks, ws.
183+
if current.native_extends.is_some() {
184+
return true;
185+
}
186+
// `class X extends <expr>` — the parent is a runtime value; its
187+
// constructor is opaque to this analysis.
188+
if current.extends_expr.is_some() || current.heritage_lexically_shadowed {
189+
return true;
190+
}
191+
let Some(parent_name) = current.extends_name.as_deref() else {
192+
// Chain bottoms out in a root user class: fully modeled.
193+
return false;
194+
};
195+
match classes.get(parent_name) {
196+
Some(parent) => current = parent,
197+
// A parent name with no visible class behind it — a builtin, or an
198+
// import whose stub never landed. Unmodeled either way.
199+
None => return true,
200+
}
201+
}
202+
}
203+
131204
pub fn stmts_use_this_as_value(stmts: &[perry_hir::Stmt], fields: &HashSet<String>) -> bool {
132205
use perry_hir::Stmt;
133206
for s in stmts {
@@ -498,4 +571,92 @@ mod tests {
498571

499572
assert!(!class_chain_extends_builtin_error(&child, &classes));
500573
}
574+
575+
// ── #6343: unmodeled-base chain walk ──
576+
577+
/// A root user class with no heritage is fully modeled — scalar
578+
/// replacement must stay available (this is the #945 fast path).
579+
#[test]
580+
fn unmodeled_base_allows_plain_class() {
581+
let plain = class("Plain", None);
582+
let mut classes = HashMap::new();
583+
classes.insert(plain.name.clone(), &plain);
584+
585+
assert!(!class_chain_has_unmodeled_base(&plain, &classes));
586+
}
587+
588+
/// A chain of ordinary user classes is fully modeled too.
589+
#[test]
590+
fn unmodeled_base_allows_user_class_chain() {
591+
let base = class("Base", None);
592+
let mid = class("Mid", Some("Base"));
593+
let leaf = class("Leaf", Some("Mid"));
594+
let mut classes = HashMap::new();
595+
classes.insert(base.name.clone(), &base);
596+
classes.insert(mid.name.clone(), &mid);
597+
classes.insert(leaf.name.clone(), &leaf);
598+
599+
assert!(!class_chain_has_unmodeled_base(&leaf, &classes));
600+
}
601+
602+
/// `class X extends EventEmitter` — the native base installs its surface as
603+
/// own properties on the instance, so the instance must stay on the heap.
604+
#[test]
605+
fn unmodeled_base_rejects_direct_native_parent() {
606+
let mut child = class("X", Some("EventEmitter"));
607+
child.native_extends = Some(("events".to_string(), "EventEmitter".to_string()));
608+
let mut classes = HashMap::new();
609+
classes.insert(child.name.clone(), &child);
610+
611+
assert!(class_chain_has_unmodeled_base(&child, &classes));
612+
}
613+
614+
/// The native base is found by walking the CHAIN, not by matching the
615+
/// leaf's own `extends` name: `class Leaf extends Mid`, `class Mid extends
616+
/// EventEmitter`.
617+
#[test]
618+
fn unmodeled_base_rejects_indirect_native_parent() {
619+
let mut mid = class("Mid", Some("EventEmitter"));
620+
mid.native_extends = Some(("events".to_string(), "EventEmitter".to_string()));
621+
let leaf = class("Leaf", Some("Mid"));
622+
let mut classes = HashMap::new();
623+
classes.insert(mid.name.clone(), &mid);
624+
classes.insert(leaf.name.clone(), &leaf);
625+
626+
assert!(class_chain_has_unmodeled_base(&leaf, &classes));
627+
}
628+
629+
/// A parent name with no class behind it (a builtin such as `Error` /
630+
/// `Map`, or an import whose stub never landed) is unmodeled.
631+
#[test]
632+
fn unmodeled_base_rejects_unresolvable_parent_name() {
633+
let child = class("MyError", Some("Error"));
634+
let mut classes = HashMap::new();
635+
classes.insert(child.name.clone(), &child);
636+
637+
assert!(class_chain_has_unmodeled_base(&child, &classes));
638+
}
639+
640+
/// `class X extends <expr>` — an arbitrary runtime parent value.
641+
#[test]
642+
fn unmodeled_base_rejects_dynamic_parent_expr() {
643+
let mut child = class("X", None);
644+
child.extends_expr = Some(Box::new(Expr::LocalGet(0)));
645+
let mut classes = HashMap::new();
646+
classes.insert(child.name.clone(), &child);
647+
648+
assert!(class_chain_has_unmodeled_base(&child, &classes));
649+
}
650+
651+
/// A cyclic chain proves nothing, so it must fail closed (escape).
652+
#[test]
653+
fn unmodeled_base_fails_closed_on_cyclic_parent_chain() {
654+
let child = class("A", Some("B"));
655+
let parent = class("B", Some("A"));
656+
let mut classes = HashMap::new();
657+
classes.insert(child.name.clone(), &child);
658+
classes.insert(parent.name.clone(), &parent);
659+
660+
assert!(class_chain_has_unmodeled_base(&child, &classes));
661+
}
501662
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// #6343: escape analysis must not scalar-replace an instance whose class chain
2+
// reaches a base that codegen cannot fully model.
3+
//
4+
// perry's native bases (`EventEmitter`, the `node:stream` classes, …) install
5+
// their method surface as OWN PROPERTIES on the instance at subclass-init time
6+
// — `js_object_set_field_by_name(obj, "emit", <native closure>)` — not on a
7+
// prototype. Scalar replacement promotes a non-escaping instance to one alloca
8+
// per DECLARED field, so a runtime-installed own property has no slot in the
9+
// promoted set and reads back `undefined`.
10+
//
11+
// The trigger is that the instance never ESCAPES: a method call (`x.on(…)`)
12+
// forces the heap path and hides the bug, so every probe below deliberately
13+
// only *reads* properties off its receiver. A declared field alongside the
14+
// read is what makes the instance look worth promoting.
15+
//
16+
// The controls are the other half: a class with no unmodeled base must STILL
17+
// scalar-replace — that is a real perf win (see
18+
// scripts/run_issue_945_scalar_method_ir_guard.sh), so the disqualifier has to
19+
// be precise rather than a blanket disable.
20+
21+
import { EventEmitter } from "node:events";
22+
import { Readable, Writable } from "node:stream";
23+
24+
// ── the issue's exact repro: declared field + bare reads, no method call ──
25+
class X extends EventEmitter {
26+
a = 1;
27+
}
28+
function probeX(): string {
29+
const x = new X();
30+
x.a = 2;
31+
return `${x.a} ${typeof x.emit} ${typeof x.on}`;
32+
}
33+
console.log("X:", probeX());
34+
35+
// same shape at module scope (the local is not referenced from any function,
36+
// so it stays a plain init-time local — still a promotion candidate)
37+
const moduleX = new X();
38+
console.log("moduleX:", moduleX.a, typeof moduleX.emit, typeof moduleX.on);
39+
40+
// ── no declared field at all: the promoted set is simply empty ──
41+
class NoField extends EventEmitter {}
42+
function probeNoField(): string {
43+
const n = new NoField();
44+
return `${typeof n.emit} ${typeof n.on} ${typeof n.once}`;
45+
}
46+
console.log("NoField:", probeNoField());
47+
48+
// ── several fields, a post-construction write, and more of the surface ──
49+
class Multi extends EventEmitter {
50+
count = 0;
51+
label = "start";
52+
flag = true;
53+
}
54+
function probeMulti(): string {
55+
const m = new Multi();
56+
m.count = 5;
57+
m.label = "changed";
58+
return `${m.count} ${m.label} ${m.flag} ${typeof m.once} ${typeof m.removeAllListeners} ${typeof m.listenerCount}`;
59+
}
60+
console.log("Multi:", probeMulti());
61+
62+
// ── node:stream bases install the same way ──
63+
class R extends Readable {
64+
pushes = 0;
65+
}
66+
function probeR(): string {
67+
const r = new R();
68+
r.pushes = 3;
69+
return `${r.pushes} ${typeof r.on} ${typeof r.push} ${typeof r.pipe}`;
70+
}
71+
console.log("R:", probeR());
72+
73+
class W extends Writable {
74+
written = 0;
75+
}
76+
function probeW(): string {
77+
const w = new W();
78+
w.written = 4;
79+
return `${w.written} ${typeof w.on} ${typeof w.write} ${typeof w.end}`;
80+
}
81+
console.log("W:", probeW());
82+
83+
// ── the surface must be LIVE, not merely present ──
84+
class Bus extends EventEmitter {
85+
seen = 0;
86+
}
87+
const bus = new Bus();
88+
bus.on("ping", (v: number) => {
89+
bus.seen += v;
90+
console.log("bus listener got:", v);
91+
});
92+
console.log("bus emit:", bus.emit("ping", 42), "seen:", bus.seen);
93+
94+
// ── explicit constructor + super(): already escaped via `super`, keep working ──
95+
class Ctor extends EventEmitter {
96+
seen = 0;
97+
constructor(start: number) {
98+
super();
99+
this.seen = start;
100+
}
101+
}
102+
function probeCtor(): string {
103+
const c = new Ctor(3);
104+
c.seen = 9;
105+
return `${c.seen} ${typeof c.emit}`;
106+
}
107+
console.log("Ctor:", probeCtor());
108+
109+
// ── an Error subclass (#573's sibling disqualifier) still works ──
110+
class MyError extends Error {
111+
code = 42;
112+
}
113+
function probeErr(): string {
114+
const e = new MyError("boom");
115+
return `${e.message} ${e.code}`;
116+
}
117+
console.log("MyError:", probeErr());
118+
119+
// ── an INDIRECT native base: declared fields on both hops must survive ──
120+
// (the emitter SURFACE on an indirect chain is a separate open gap — #6326 —
121+
// so this probes fields only, which is what this issue is about.)
122+
class Mid extends EventEmitter {
123+
mid = 7;
124+
}
125+
class Leaf extends Mid {
126+
leaf = 8;
127+
}
128+
function probeLeaf(): string {
129+
const l = new Leaf();
130+
l.leaf = 9;
131+
return `${l.mid} ${l.leaf}`;
132+
}
133+
console.log("Leaf:", probeLeaf());
134+
135+
// ── CONTROL: a plain class with no unmodeled base MUST still scalar-replace ──
136+
class Point {
137+
x: number;
138+
y: number;
139+
constructor(x: number, y: number) {
140+
this.x = x;
141+
this.y = y;
142+
}
143+
getX(): number {
144+
return this.x;
145+
}
146+
}
147+
function hot(n: number): number {
148+
let sum = 0;
149+
for (let i = 0; i < n; i++) {
150+
const p = new Point(i, i * 2);
151+
sum += p.getX() + p.y;
152+
}
153+
return sum;
154+
}
155+
console.log("plain class (scalar-replaced):", hot(5));
156+
157+
// ── CONTROL: a user-class chain is fully modeled — still scalar-replaced ──
158+
class Base {
159+
base = 10;
160+
}
161+
class Derived extends Base {
162+
own = 20;
163+
total(): number {
164+
return this.base + this.own;
165+
}
166+
}
167+
function probeDerived(): string {
168+
const d = new Derived();
169+
return `${d.base} ${d.own} ${d.total()}`;
170+
}
171+
console.log("user chain:", probeDerived());

0 commit comments

Comments
 (0)