Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions crates/perry-codegen/src/expr/this_super_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,46 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
lowered_args.push(lower_expr(ctx, a)?);
}

// #6326: the parent is a real user class, but the chain BOTTOMS OUT
// in a native base whose surface perry stamps onto the instance —
// `class Counter extends B { constructor() { super(); … } }` with
// `class B extends EventEmitter {}`. The builtin arms above only fire
// when the IMMEDIATE parent name IS the base, so they never see this
// shape; and the parent-chain walk below finds no constructor to
// inline (no ancestor has one), so `super()` silently no-oped and the
// instance came out with no emitter/collection surface at all.
//
// The walk yields `None` the moment any ancestor has a constructor —
// that ancestor's own `super()` installs the base — so this arm fires
// exactly when nothing else will.
if let Some(base) = crate::lower_call::native_instance_base_in_chain(ctx, current_class)
{
let this_box = match ctx.this_stack.last().cloned() {
Some(slot) => ctx.block().load(DOUBLE, &slot),
None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)),
};
crate::lower_call::emit_native_instance_base_init(
ctx,
base,
&this_box,
&lowered_args,
);
// Spec: derived-class field initializers run AFTER `super()`
// returns. The native base is the chain root and has no TS
// fields, so everything after it still needs initializing —
// including ctor-less intermediates, which write no `super()`
// of their own and so have no other site that would do it.
// `AncestorsOnly` only covers the root, so `SelfOnly` here
// would leave a middle class like `B` in
// `C -> B -> A -> EventEmitter` uninitialized.
crate::lower_call::apply_field_initializers_recursive(
ctx,
&current_class_name,
crate::lower_call::FieldInitMode::AfterRoot,
)?;
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Inline the parent constructor with the SAME this and a
// fresh param scope for the parent's params.
//
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/lower_call/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ pub(crate) use new_helpers::{
ctor_body_calls_super, ctor_body_closure_calls_super, ctor_body_has_value_return,
ctor_body_uses_this,
};
// #6325 / #6326: the class-chain walk to a native base whose surface perry
// stamps onto the instance, plus its init emitter. Shared by the implicit
// (no-own-ctor) `new` path in `new.rs` and the explicit-`super()` arm in
// `expr/this_super_call.rs`, which are the two places a derived constructor can
// reach the base.
pub(crate) use new_helpers::{emit_native_instance_base_init, native_instance_base_in_chain};
// `extract_options_fields` is consumed by `expr.rs` as
// `crate::lower_call::extract_options_fields` — keep that path stable.
pub(crate) use options::extract_options_fields;
Expand Down
39 changes: 28 additions & 11 deletions crates/perry-codegen/src/lower_call/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1514,17 +1514,34 @@ fn lower_new_impl(
found_inherited_ctor = true;
}
}
// #5137: implicit-ctor `class X extends EventEmitter {}` — install the
// emitter surface (the explicit-`super()` arm does this when a ctor is
// written). Gated `!has_imported_ctor` so an imported class whose real
// ctor lives in another module (commander's `Command`) still reaches
// the imported-ctor fallback below and runs its real `super()`.
if !found_inherited_ctor
&& !has_imported_ctor
&& class.extends_name.as_deref() == Some("EventEmitter")
{
crate::expr::lower_event_emitter_subclass_init(ctx, &obj_box);
found_inherited_ctor = true;
// #5137 / #6325 / #6326: implicit-ctor subclass of a native base whose
// surface perry stamps onto the INSTANCE — `EventEmitter`, `Map`/`Set`,
// `Event`/`CustomEvent`. The explicit-`super()` arm
// (`expr/this_super_call.rs`) installs it when a constructor is written;
// a class with no own constructor writes no `super()`, so the install
// has to happen here or the instance is left bare (`class M extends Map
// {}` → `m.set` is not a function).
//
// Keyed on the class CHAIN reaching the base rather than on a literal
// `extends` name: an INDIRECT subclass names an intermediate USER class
// (`class D extends B {}` with `class B extends EventEmitter {}`), so the
// old one-level name test lost the base entirely. The walk stops at any
// ancestor with a constructor — its `super()` does the install — so this
// never double-initializes.
//
// Gated `!has_imported_ctor` so an imported class whose real ctor lives
// in another module (commander's `Command`) still reaches the
// imported-ctor fallback below and runs its real `super()`.
if !found_inherited_ctor && !has_imported_ctor {
if let Some(base) = crate::lower_call::native_instance_base_in_chain(ctx, class) {
crate::lower_call::emit_native_instance_base_init(
ctx,
base,
&obj_box,
&lowered_args,
);
found_inherited_ctor = true;
}
}
// Issue #573: if the parent walk reached an Error-like built-in
// without finding any user-class constructor, synthesize the JS
Expand Down
164 changes: 163 additions & 1 deletion crates/perry-codegen/src/lower_call/new_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,169 @@
use perry_hir::{Class, Expr};

use crate::expr::FnCtx;
use crate::types::DOUBLE;
use crate::types::{DOUBLE, I32};

/// The native base classes perry models by STAMPING state onto the INSTANCE at
/// construction time instead of giving it a real builtin prototype: the instance
/// is a plain `ObjectHeader`, and `super()` installs the base's surface on it —
/// an own-property method bag for `EventEmitter`, a hidden backing
/// `MapHeader`/`SetHeader` for `Map`/`Set`, the standard event fields for
/// `Event`/`CustomEvent`.
///
/// Because the install rides on `super()`, it used to be keyed on the class
/// naming the base LITERALLY in its own `extends` clause. That misses the base
/// in two directions:
///
/// * a class with no own constructor writes no `super()` at all, so the init
/// never ran — `class M extends Map {}` produced an instance with no
/// collection storage and therefore no `set`/`get`/`size` (#6325); and
/// * an INDIRECT subclass names an intermediate USER class, not the base, so
/// the init never ran either — `class D extends B {}` with `class B extends
/// EventEmitter {}` produced an object with no emitter surface (#6326).
///
/// Both are the same hole, and both close by triggering on "the class CHAIN
/// reaches the base" — see [`native_instance_base_in_chain`].
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum NativeInstanceBase {
EventEmitter,
Map,
Set,
Event,
CustomEvent,
}

/// The native base a parent NAME denotes, if any.
///
/// Deliberately narrow. The other builtin bases perry supports have their own
/// construction machinery that the chain walk must not preempt: `Error` &
/// friends are handled by the Error-init arms, the `node:stream` classes by
/// `node_stream_parent_kind` (which already walks the chain), `Request`/
/// `Response` by the fetch-handle shims, `Promise` by the backing-cell shim,
/// and `Array` by `js_array_subclass_init` (whose `super(n)` argument is a
/// length, not an iterable — a no-arg implicit ctor has nothing to forward).
pub(crate) fn native_instance_base(name: &str) -> Option<NativeInstanceBase> {
match name {
"EventEmitter" => Some(NativeInstanceBase::EventEmitter),
"Map" => Some(NativeInstanceBase::Map),
"Set" => Some(NativeInstanceBase::Set),
"Event" => Some(NativeInstanceBase::Event),
"CustomEvent" => Some(NativeInstanceBase::CustomEvent),
_ => None,
}
}

/// The native base `class` ultimately derives from, found by walking
/// `extends_name` through user classes that carry no constructor of their own.
///
/// The walk STOPS (yielding `None`) at any ancestor that HAS a constructor —
/// local or imported. Such an ancestor's `super()` performs the base init
/// itself, and running it a second time here would re-stamp the surface over
/// already-live state: a fresh listener bag over an emitter the ancestor's ctor
/// already registered listeners on, a fresh empty backing over a seeded Map.
/// This is the same ctor-less walk `node_stream_parent_kind` performs for the
/// classic `node:stream` bases — generalized, not invented.
///
/// A user class in this module SHADOWS the builtin name: with `class Map {}` in
/// the source, `ctx.classes` resolves `Map` first, so the walk descends into the
/// user class and never reports a native base.
pub(crate) fn native_instance_base_in_chain(
ctx: &FnCtx<'_>,
class: &Class,
) -> Option<NativeInstanceBase> {
let mut cur = class.extends_name.as_deref();
for _ in 0..32 {
let name = cur?;
if ctx.imported_class_ctors.contains_key(name) {
return None;
}
match ctx.classes.get(name).copied() {
// A user class in this module: keep walking only while it delegates
// construction upward (no ctor of its own).
Some(parent) => {
if parent.constructor.is_some() {
return None;
}
cur = parent.extends_name.as_deref();
}
// Not a class in this module — the chain has reached a builtin.
None => return native_instance_base(name),
}
}
None
}

/// Install a native base's surface on `this_box`.
///
/// `lowered_args` are the already-lowered constructor arguments. The JS spec's
/// implicit derived constructor is `constructor(...args) { super(...args) }`, so
/// forwarding them is exactly what a written-out `super(...)` would have done —
/// which is how `new Seeded([["k", 9]])` on a `class Seeded extends Map {}`
/// seeds its backing, and how `new (class extends Event {})("tick")` gets its
/// `type`.
pub(crate) fn emit_native_instance_base_init(
ctx: &mut FnCtx<'_>,
base: NativeInstanceBase,
this_box: &str,
lowered_args: &[String],
) {
let undef = crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED));
match base {
NativeInstanceBase::EventEmitter => {
// The bare emitter seeds no state from its options bag, so the args
// (already lowered for their side effects) are not forwarded.
crate::expr::lower_event_emitter_subclass_init(ctx, this_box);
}
NativeInstanceBase::Map | NativeInstanceBase::Set => {
let kind: i32 = if base == NativeInstanceBase::Map {
0
} else {
1
};
// `new Map(iterable)` / `new Set(iterable)` — extra args are ignored
// by the builtin, and `undefined` seeds an empty collection.
let iterable = lowered_args.first().cloned().unwrap_or(undef);
ctx.block().call(
DOUBLE,
"js_map_set_subclass_init",
&[
(DOUBLE, this_box),
(I32, &kind.to_string()),
(DOUBLE, &iterable),
],
);
}
NativeInstanceBase::Event | NativeInstanceBase::CustomEvent => {
let arg0 = lowered_args
.first()
.cloned()
.unwrap_or_else(|| undef.clone());
let arg1 = lowered_args
.get(1)
.cloned()
.unwrap_or_else(|| undef.clone());
// `argc` drives the runtime's missing-`type` throw, matching Node's
// `new Event()` TypeError.
let argc = lowered_args.len().min(2).to_string();
let is_custom = if base == NativeInstanceBase::CustomEvent {
"1"
} else {
"0"
}
.to_string();
ctx.block().call(
DOUBLE,
"js_event_subclass_init",
&[
(DOUBLE, this_box),
(DOUBLE, &arg0),
(DOUBLE, &arg1),
(I32, &argc),
(I32, &is_custom),
],
);
}
}
}

/// Emit `js_promise_subclass_init(this, executor)` for a no-own-ctor
/// `class X extends Promise {}` on the runtime `new X(executor)` path. Runs the
Expand Down
38 changes: 35 additions & 3 deletions crates/perry-hir/src/lower/expr_new/non_ident.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,25 @@ pub(crate) fn lower_new_non_ident(
let synthetic_name = format!("__anon_class_{}", ctx.fresh_class());
ctx.pending_class_inner_name = class_expr.ident.as_ref().map(|i| i.sym.to_string());
let class = lower_class_from_ast(ctx, &class_expr.class, &synthetic_name, false)?;
// #6336: a class expression's `Subclass → Parent` registry edge is a SIDE
// EFFECT of evaluating the expression — `lower_class_expr` sequences a
// `RegisterClassParentDynamic` in front of the `ClassRef` it yields
// (`const K = class extends Event {}` works because of it). This arm
// never went through `lower_class_expr`: it lowers the class straight to
// a `New` on the synthetic name, so for a class expression constructed
// IN PLACE the registration never ran and the instance came out
// parentless — `new (class extends Event {})("tick") instanceof Event`
// was `false`, and every chain walk that identifies a receiver by its
// base (`instanceof`, the native-base init, Event/EventTarget dispatch)
// bailed. Sequence the same registration in front of the `New` so the
// edge is wired before the constructor runs.
//
// Only heritage that resolves to a runtime VALUE carries `extends_expr`
// (a builtin like `Event`, a factory call, a captured local). A parent
// that is a known user class in this module carries a static `extends`
// link instead and needs no registration — which is why the user-parent
// form of this shape already worked.
let parent_expr = class.extends_expr.clone();
ctx.pending_classes.push(class);
let mut args: Vec<Expr> = new_expr
.args
Expand Down Expand Up @@ -173,12 +192,25 @@ pub(crate) fn lower_new_non_ident(
.collect()
})
.unwrap_or_default();
return Ok(Expr::New {
class_name: synthetic_name,
let construct = Expr::New {
class_name: synthetic_name.clone(),
args,
type_args,
byte_offset: new_byte_offset,
});
};
// The `Sequence` yields its LAST element, so the `new` site still sees
// the constructed instance — the registration is pure side effect,
// ordered before it.
let Some(parent_expr) = parent_expr else {
return Ok(construct);
};
return Ok(Expr::Sequence(vec![
Expr::RegisterClassParentDynamic {
class_name: synthetic_name,
parent_expr,
},
construct,
]));
}

let callee = Box::new(lower_expr(ctx, callee_expr)?);
Expand Down
14 changes: 13 additions & 1 deletion crates/perry-runtime/src/object/class_constructors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,19 @@ unsafe fn call_displaced_native_base_method(
) -> f64 {
let Some(method_value) = crate::node_stream::displaced_native_base_method(this_value, name)
else {
return undef;
// #6325: the `Map`/`Set` bases serve their surface by redirecting the
// OPERATION onto a hidden backing collection, not by stamping method
// closures onto the instance — so an override displaces nothing and
// there is no closure here to call. `super.get(k)` / `super.set(k, v)` /
// … dispatch on the backing instead. Returns `undef` for a receiver with
// no backing, keeping the ordinary `super.m()`-miss contract.
let args: &[f64] = if args_ptr.is_null() || args_len == 0 {
&[]
} else {
std::slice::from_raw_parts(args_ptr, args_len)
};
return crate::object::map_set_subclass::super_collection_method(this_value, name, args)
.unwrap_or(undef);
};
// The stashed closure already captures the receiver in slot 0, but bind
// IMPLICIT_THIS too: the shared emitter/stream stubs read their receiver
Expand Down
Loading
Loading