Skip to content
Merged
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
34 changes: 34 additions & 0 deletions changelog.d/11809-eventemitter-shared-prototype.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
**perf/parity: EventEmitter methods live on one shared prototype (Refs #10508)**

`class X extends EventEmitter`, `util.inherits` + `EventEmitter.call(this)`,
`Object.create(EventEmitter.prototype)` and `setPrototypeOf` mixins no longer
get the 15 emitter methods stamped onto every instance as own closures. As in
node, `super()` / `EventEmitter.call(this)` run `EventEmitter.init`: the
instance owns `_events` (a null-prototype object), `_eventsCount` and
`_maxListeners`, and the methods are inherited from `EventEmitter.prototype`,
which carries node's defaults (`_events: undefined`, `_eventsCount: 0`,
`_maxListeners: undefined`) and its aliases (`addListener === on`,
`off === removeListener`).

- `Object.keys(new Sub())` is `["_events","_eventsCount","_maxListeners"]`,
`hasOwnProperty('on')` is false and `sub.on === EventEmitter.prototype.on`,
matching node.
- The listener store is node's: `_events[type]` holds one function or an
array; a `once` listener is a wrapper with `.listener`; `rawListeners`,
`listeners`, `eventNames`, `newListener` / `removeListener` and
`removeAllListeners` follow `lib/events.js`, so code that reads or edits
`_events` directly sees the same state.
- A subclass override still wins, and `super.emit()` / `super.on()` reach the
base through the prototype chain (the #6316 behavior, without the
per-instance stash).
- `super({ captureRejections: true })` now works for subclasses: an async
listener's rejection reaches `[Symbol.for('nodejs.rejection')]` or
`emit('error')`; a non-boolean value throws `ERR_INVALID_ARG_TYPE`.

Instructions per operation (t508, `class Q extends EventEmitter` with 12
fields): `new` 151.3k -> 77.1k (RSS 108 MB -> 47 MB), `emit` 51.1k -> 34.2k,
`this.m()` 190 -> 190. Commander `parse_argv` 13.39G -> 10.43G instructions
(-22.1%); tsc and Zod unchanged.

Plain `new EventEmitter()` is still a handle (its own keys are still `[]`).
That is the next step.
45 changes: 26 additions & 19 deletions crates/perry-codegen/src/expr/this_super_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -915,28 +915,35 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
}
// #5137: `class X extends EventEmitter` (node:events) —
// `super()` installs the bare EventEmitter listener/emit
// surface onto `this` (see `lower_event_emitter_subclass_init`).
// `super(opts)` takes an optional options bag in Node; we lower
// the args for side effects but the bare emitter seeds no state.
// `super(opts)` runs node's `EventEmitter.init(opts)` on
// `this` (see `lower_event_emitter_subclass_init`); the
// methods are inherited from the shared
// `EventEmitter.prototype`. Every argument is lowered (in
// order, rooted across the later ones); the first is the
// options bag (`captureRejections`).
if parent_name.as_str() == "EventEmitter" {
for a in super_args {
let _ = lower_expr(ctx, a)?;
}
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)),
};
lower_event_emitter_subclass_init(ctx, &this_box);
bind_derived_this_after_super(ctx);
let operands: Vec<_> = super_args.iter().collect();
let current_class_name =
ctx.class_stack.last().cloned().unwrap_or_default();
crate::lower_call::apply_field_initializers_recursive(
ctx,
&current_class_name,
crate::lower_call::FieldInitMode::SelfOnly,
)?;
return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)));
return rooting::with_operands_rooted(ctx, &operands, |ctx, lowered| {
let options = lowered.first().cloned().unwrap_or_else(|| {
double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))
});
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))
}
};
lower_event_emitter_subclass_init(ctx, &this_box, &options);
bind_derived_this_after_super(ctx);
crate::lower_call::apply_field_initializers_recursive(
ctx,
&current_class_name,
crate::lower_call::FieldInitMode::SelfOnly,
)?;
Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)))
});
}
if parent_name.as_str() == "EventEmitterAsyncResource" {
let operands: Vec<_> = super_args.iter().collect();
Expand Down
20 changes: 12 additions & 8 deletions crates/perry-codegen/src/expr/write_barrier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1135,21 +1135,25 @@ pub(crate) fn lower_array_super_init(ctx: &mut FnCtx<'_>, super_args: &[Expr]) -
Ok(undef_lit)
}

/// #5137: install the bare EventEmitter listener/emit surface onto `this_box`
/// for a source-compiled `class X extends EventEmitter` (node:events). Shared
/// by the explicit-`super()` arm (`expr/this_super_call.rs`) and the
/// #5137: node's `EventEmitter.init` on `this_box` for a source-compiled
/// `class X extends EventEmitter` (node:events). Shared by the
/// explicit-`super()` arm (`expr/this_super_call.rs`) and the
/// no-own-constructor `new` path (`lower_call/new.rs`). The runtime helper
/// reuses the generic `ns_*` emitter closures (they key all state off the
/// receiver), so a plain object that never went through a stream constructor
/// gets working `.on`/`.emit`/`.once`/…. Reached when an EventEmitter
/// gives `this` its own `_events`/`_eventsCount`/`_maxListeners`; the
/// `.on`/`.emit`/`.once`/… methods are inherited from the shared
/// `EventEmitter.prototype`, as in node. Reached when an EventEmitter
/// subclass's real npm source is compiled — e.g. commander's `Command` under
/// `perry.compilePackages`, where the `new Command()` → `js_commander_*`
/// native-shim path is intentionally off.
pub(crate) fn lower_event_emitter_subclass_init(ctx: &mut FnCtx<'_>, this_box: &str) {
pub(crate) fn lower_event_emitter_subclass_init(
ctx: &mut FnCtx<'_>,
this_box: &str,
options_box: &str,
) {
ctx.block().call(
DOUBLE,
"js_event_emitter_subclass_init",
&[(DOUBLE, this_box)],
&[(DOUBLE, this_box), (DOUBLE, options_box)],
);
}

Expand Down
10 changes: 7 additions & 3 deletions crates/perry-codegen/src/lower_call/new_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,13 @@ pub(crate) fn emit_native_instance_base_init(
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);
// node's `EventEmitter.init(opts)`: the first argument is the
// options bag (`captureRejections`).
let options = lowered_args
.first()
.cloned()
.unwrap_or_else(|| undef.clone());
crate::expr::lower_event_emitter_subclass_init(ctx, this_box, &options);
}
NativeInstanceBase::Array => {
let n = lowered_args.len();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::types::{DOUBLE, I32, I64, PTR};

pub(crate) fn declare_streams_events(module: &mut LlModule) {
// ========== node:stream stubs (issue #631) ==========
module.declare_function("js_event_emitter_subclass_init", DOUBLE, &[DOUBLE]); // #5137 EE subclass init
module.declare_function("js_event_emitter_subclass_init", DOUBLE, &[DOUBLE, DOUBLE]); // #5137 EE subclass init (this, options)
module.declare_function(
"js_event_emitter_async_resource_subclass_init",
DOUBLE,
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/wasm32/runtime_abi.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,7 @@ js_event_emitter_remove_all_listeners i64 i64,ptr
js_event_emitter_remove_listener i64 i64,i64,i64
js_event_emitter_set_domain i32s i64,i64
js_event_emitter_set_max_listeners i64 i64,f64
js_event_emitter_subclass_init f64 f64
js_event_emitter_subclass_init f64 f64,f64
js_event_loop_check_phase i32s
js_event_loop_host_driven i32s
js_event_loop_poll_callbacks i32s
Expand Down
6 changes: 3 additions & 3 deletions crates/perry-runtime/src/closure/dispatch/value_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ unsafe fn native_call_value_this_impl(
// the direct `class X extends EventEmitter` form (#5137) — never fires, and
// `js_register_class_parent_dynamic` early-returns for bound native parents.
// The dynamic super lowering (expr/this_super_call.rs) dispatches the parent
// VALUE here with `this` = the fresh subclass instance. Install
// the EventEmitter listener/emit methods onto that instance, exactly as the
// direct form does, so `this.setMaxListeners(…)`/`.on`/`.emit` resolve.
// VALUE here with `this` = the fresh subclass instance. Run node's
// `EventEmitter.init` on that instance, exactly as the direct form does;
// `this.setMaxListeners(…)`/`.on`/`.emit` resolve on the shared prototype.
// Routed through the armed ops table (see `nm_namespace_hooks`): the
// probe can only match a bound native callable, which exists only once
// `callable_exports` minted one (arming the table).
Expand Down
9 changes: 7 additions & 2 deletions crates/perry-runtime/src/node_stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ pub(crate) fn has_stream_listeners(stream: f64, event: &[u8]) -> bool {
// #3049 — `process.setMaxListeners` reuses the EventEmitter setter
// validation (TypeError/RangeError + fractional/Infinity storage).
pub(crate) use event_emitter::validate_max_listeners;
// node EventEmitter instance state (EventEmitter.init) and the prototype defaults.
pub(crate) use event_emitter::{
init_event_emitter_capture, init_event_emitter_state, install_event_emitter_prototype_state,
};
pub use event_emitter::{
js_node_stream_method_event_names, js_node_stream_method_get_max_listeners,
js_node_stream_method_listener_count, js_node_stream_method_listeners,
Expand Down Expand Up @@ -101,8 +105,9 @@ const STREAM_READABLE_SCHEDULED_KEY: &[u8] = b"__perryStreamReadableScheduled";
const STREAM_END_SCHEDULED_KEY: &[u8] = b"__perryStreamEndScheduled";
const STREAM_END_EMITTED_KEY: &[u8] = b"__perryStreamEndEmitted";
const STREAM_ENDED_KEY: &[u8] = b"__perryStreamEnded";
const STREAM_MAX_LISTENERS_KEY: &[u8] = b"__perryStreamMaxListeners";
const STREAM_CAPTURE_REJECTIONS_KEY: &[u8] = b"__perryStreamCaptureRejections";
/// An emitter's `captureRejections` flag (node's `this[kCapture]`). Internal:
/// hidden from own-key enumeration (`is_internal_runtime_key_bytes`).
pub(crate) const STREAM_CAPTURE_REJECTIONS_KEY: &[u8] = b"__perryStreamCaptureRejections";
const EVENT_EMITTER_ASYNC_RESOURCE_KEY: &[u8] = b"__perryEventEmitterAsyncResource";
const WRITABLE_WRITE_KEY: &[u8] = b"__perryWritableWrite";
const WRITABLE_FINISH_SCHEDULED_KEY: &[u8] = b"__perryWritableFinishScheduled";
Expand Down
34 changes: 24 additions & 10 deletions crates/perry-runtime/src/node_stream_constructors/builders.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,29 +66,36 @@ pub extern "C" fn js_node_stream_readable_subclass_init(this: f64, opts: f64) ->
}

/// #5137: `super()` for a source-compiled `class X extends EventEmitter`
/// (from `node:events`). Installs the bare EventEmitter listener/emit
/// methods directly onto `this` — the same generic `ns_*` closures the
/// stream subclasses use — so `.on`/`.emit`/`.once`/… resolve as the
/// instance's own bound methods. This is the EventEmitter analog of
/// (from `node:events`), and `EventEmitter.call(this)`. Gives `this` node's
/// instance state; `.on`/`.emit`/`.once`/… are inherited from the shared
/// `EventEmitter.prototype` (`install_event_emitter_prototype`), as in node.
/// This is the EventEmitter analog of
/// `js_node_stream_readable_subclass_init`; commander's `Command extends
/// EventEmitter` reaches it when its real npm source is compiled (the
/// package is in `perry.compilePackages`, so the `new Command()` → native
/// `js_commander_*` shim path is deliberately off). Unlike the stream
/// inits there is no option-driven state to seed — a plain EventEmitter
/// has no `_read`/`highWaterMark`/etc.
#[no_mangle]
pub extern "C" fn js_event_emitter_subclass_init(this: f64) -> f64 {
pub extern "C" fn js_event_emitter_subclass_init(this: f64, options: f64) -> f64 {
let raw = raw_ptr_from_value(this);
if raw == 0 {
return this;
}
if unsafe { gc_type_for_ptr(raw) } != Some(crate::gc::GC_TYPE_OBJECT) {
return this;
}
let obj = raw as *mut ObjectHeader;
let methods = emitter_methods();
install_methods_on_existing_object(obj, this, &methods, &[]);
this
// node's `EventEmitter.init`: own `_events`/`_eventsCount`/`_maxListeners`
// only. The methods are inherited from the shared `EventEmitter.prototype`
// (a subclass override on its own prototype shadows them there, and
// `super.m()` finds the base through the chain), so nothing is installed
// per instance (#10508).
let scope = crate::gc::RuntimeHandleScope::new();
let this = scope.root_nanbox_f64(this);
let options = scope.root_nanbox_f64(options);
init_event_emitter_state(this.get_nanbox_f64());
init_event_emitter_capture(this.get_nanbox_f64(), options.get_nanbox_f64());
this.get_nanbox_f64()
}

/// #10798: install the legacy `node:stream` `Stream` base surface onto
Expand Down Expand Up @@ -132,7 +139,14 @@ pub extern "C" fn js_event_emitter_async_resource_subclass_init(this: f64, optio
let scope = crate::gc::RuntimeHandleScope::new();
let this_handle = scope.root_nanbox_f64(this);
let options_handle = scope.root_nanbox_f64(options);
js_event_emitter_subclass_init(this_handle.get_nanbox_f64());
// A string `options` is the resource name; only an options object carries
// `captureRejections` for the EventEmitter half.
let emitter_options = if JSValue::from_bits(options.to_bits()).is_any_string() {
f64::from_bits(crate::value::TAG_UNDEFINED)
} else {
options_handle.get_nanbox_f64()
};
js_event_emitter_subclass_init(this_handle.get_nanbox_f64(), emitter_options);

let this = this_handle.get_nanbox_f64();
let raw = raw_ptr_from_value(this);
Expand Down
Loading
Loading