From 8ce7130dcf37daabeab67cc2258a81bb99bdcba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 08:08:02 +0200 Subject: [PATCH 1/3] fix(events): route the module-level events.* helpers through the dynamic dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `events.listenerCount(e, "x")` returned 2 while `const c = events.listenerCount; c(e, "x")` returned `undefined`. Same for `(events as any).listenerCount(...)` and `events.listenerCount(...args)`. `nm_dispatch_events` had arms only for `init` and `EventEmitterAsyncResource`; everything else fell to `_ => undefined`. The seven module-level helpers (`listenerCount`, `once`, `on`, `getEventListeners`, `getMaxListeners`, `setMaxListeners`, `addAbortListener`) are implemented in perry-stdlib, which depends on perry-runtime and so cannot be named from the dispatch bucket. The static call reaches them directly through the codegen `NativeModSig` rows, which is why only the indirect forms were dead. Add the registered-pointer bridge every comparable module already has (zlib / querystring / domain): `JS_NATIVE_EVENTS_DISPATCH` + `js_set_native_events_dispatch` in perry-runtime, `js_events_native_dispatch` in perry-stdlib, wired at `js_stdlib_init_dispatch`. Argument marshalling matches what the static path's `NA_STR` / `NA_VARARGS` rows produce: event names go through ToString, and `setMaxListeners(n, ...targets)` rebuilds its trailing targets into the single array the helper expects. Distinct from the existing `JS_NATIVE_EVENTS_CONSTRUCT`, which serves only `new`. Follow-up to #7734, which named this as the one remaining wrong-to-wrong case in the #7720 spread-call matrix: `events.listenerCount(...args)` turned a bogus ERR_INVALID_ARG_TYPE throw into `undefined`. It now returns the count. Tests: `events_dispatch_parity_tests` walks the real `NET_EVENTS_ROWS` table and fails on any `has_receiver: false` `events` row that is not classified as routed-to-stdlib, answered-by-runtime, or deliberately-unrouted — the drift that caused this bug — plus a stale-entry check so the classification cannot rot. Both halves sabotage-checked. `node-suite/events/listeners/ module-helper-dynamic-dispatch.ts` byte-compares the static, captured, type-erased and spread forms against node. `events.on`'s async ITERATION is deliberately not asserted: it drops its first value in the STATIC form too, on a tree without this change (`events/on/ async-iterator-abort` and `events/on/validation` are already red for it). The helper is routed all the same, so it inherits the fix when that gap closes. --- .../events_dispatch_parity_tests.rs | 98 +++++++++++++++++++ .../src/lower_call/native_table/mod.rs | 1 + crates/perry-runtime/src/lib.rs | 8 +- .../native_module_dispatch/dispatch_d_i.rs | 27 +++++ crates/perry-runtime/src/value/handle.rs | 12 +++ crates/perry-runtime/src/value/mod.rs | 9 +- crates/perry-runtime/src/value/tags.rs | 4 + .../perry-stdlib/src/common/dispatch/init.rs | 3 + crates/perry-stdlib/src/events.rs | 3 +- .../perry-stdlib/src/events/module_helpers.rs | 98 +++++++++++++++++++ .../module-helper-dynamic-dispatch.ts | 64 ++++++++++++ 11 files changed, 318 insertions(+), 9 deletions(-) create mode 100644 crates/perry-codegen/src/lower_call/native_table/events_dispatch_parity_tests.rs create mode 100644 test-parity/node-suite/events/listeners/module-helper-dynamic-dispatch.ts diff --git a/crates/perry-codegen/src/lower_call/native_table/events_dispatch_parity_tests.rs b/crates/perry-codegen/src/lower_call/native_table/events_dispatch_parity_tests.rs new file mode 100644 index 0000000000..e7bf590fe1 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/native_table/events_dispatch_parity_tests.rs @@ -0,0 +1,98 @@ +//! The `node:events` module-helper name list lives in THREE places, and they +//! must agree. This test is the only thing that makes a disagreement loud. +//! +//! 1. **here** — the `has_receiver: false` `events` rows of [`NET_EVENTS_ROWS`], +//! which serve the STATIC call `events.listenerCount(e, "x")`; +//! 2. `nm_dispatch_events` in perry-runtime, which serves every INDIRECT form +//! (captured value, type-erased receiver, spread call) and can only reach +//! the stdlib helpers through a registered function pointer; +//! 3. `js_events_native_dispatch` in perry-stdlib, the other end of that +//! pointer. +//! +//! When (1) had rows that (2) and (3) did not, the static call was correct and +//! every indirect form silently answered `undefined` — `const c = +//! events.listenerCount; c(e, "x")` returned `undefined` where node returns a +//! count. That is invisible to a static-form test, which is why the drift +//! survived; it took a spread call routed onto the dynamic path to surface it. +//! +//! A new module-level `events` export therefore has to be CLASSIFIED below, not +//! merely added to the table. The test fails on an unclassified row rather than +//! letting it ship with a dead dynamic path. + +#![cfg(test)] + +use super::net_events::NET_EVENTS_ROWS; + +/// Routed by `nm_dispatch_events` to perry-stdlib's `js_events_native_dispatch`. +/// Keep byte-identical to the match arm there and to the `match name` arms in +/// the stdlib bridge. +const ROUTED_TO_STDLIB_BRIDGE: &[&str] = &[ + "listenerCount", + "once", + "on", + "getEventListeners", + "getMaxListeners", + "setMaxListeners", + "addAbortListener", +]; + +/// Answered by perry-runtime directly — no stdlib bridge needed. +/// `init` is a no-op returning `undefined`; `EventEmitterAsyncResource` throws +/// the "cannot be invoked without 'new'" TypeError. +const ANSWERED_BY_RUNTIME: &[&str] = &["init", "EventEmitterAsyncResource"]; + +/// Deliberately unrouted: `events.EventEmitter` is a CONSTRUCTOR. `new`-ing it +/// dynamically goes through `JS_NATIVE_EVENTS_CONSTRUCT`, a separate pointer; +/// calling it as a plain function through the dynamic path is not supported and +/// is not what the row serves. +const NOT_A_DYNAMIC_MODULE_CALL: &[&str] = &["EventEmitter"]; + +fn module_level_events_methods() -> Vec<&'static str> { + let mut names: Vec<&'static str> = NET_EVENTS_ROWS + .iter() + .filter(|row| row.module == "events" && !row.has_receiver) + .map(|row| row.method) + .collect(); + names.sort_unstable(); + names.dedup(); + names +} + +#[test] +fn every_events_module_helper_is_classified_for_dynamic_dispatch() { + for method in module_level_events_methods() { + let classified = ROUTED_TO_STDLIB_BRIDGE.contains(&method) + || ANSWERED_BY_RUNTIME.contains(&method) + || NOT_A_DYNAMIC_MODULE_CALL.contains(&method); + assert!( + classified, + "`events.{method}` has a static NativeModSig row but no decision about the \ + dynamic path. Add it to `nm_dispatch_events` (perry-runtime) AND \ + `js_events_native_dispatch` (perry-stdlib), then list it in \ + ROUTED_TO_STDLIB_BRIDGE — or justify it in one of the other two lists. \ + Leaving it unclassified ships a helper whose captured / type-erased / \ + spread forms silently return `undefined`." + ); + } +} + +#[test] +fn classification_lists_do_not_name_rows_that_no_longer_exist() { + // A stale entry is the mirror-image failure: it makes the test above pass + // for a name the table dropped, so the next real addition slips through + // unnoticed. + let existing = module_level_events_methods(); + for list in [ + ROUTED_TO_STDLIB_BRIDGE, + ANSWERED_BY_RUNTIME, + NOT_A_DYNAMIC_MODULE_CALL, + ] { + for method in list { + assert!( + existing.contains(method), + "`events.{method}` is classified here but has no `has_receiver: false` \ + row in NET_EVENTS_ROWS — delete the stale entry." + ); + } + } +} diff --git a/crates/perry-codegen/src/lower_call/native_table/mod.rs b/crates/perry-codegen/src/lower_call/native_table/mod.rs index d8d7165c7d..c471c12bc6 100644 --- a/crates/perry-codegen/src/lower_call/native_table/mod.rs +++ b/crates/perry-codegen/src/lower_call/native_table/mod.rs @@ -16,6 +16,7 @@ mod async_decimal; mod bun; mod databases; mod dates; +mod events_dispatch_parity_tests; mod extras; mod fastify; mod http_client; diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 79df150444..6ba4daadf6 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -310,10 +310,10 @@ pub use value::{ js_set_handle_array_get, js_set_handle_array_length, js_set_handle_call_method, js_set_handle_object_get_property, js_set_handle_to_string, js_set_handle_typeof, js_set_native_async_hooks_construct, js_set_native_crypto_dispatch, - js_set_native_domain_dispatch, js_set_native_events_construct, js_set_native_http_dispatch, - js_set_native_module_js_loader, js_set_native_querystring_dispatch, - js_set_native_sqlite_dispatch, js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, - js_set_native_zlib_dispatch, js_set_new_from_handle_v8, + js_set_native_domain_dispatch, js_set_native_events_construct, js_set_native_events_dispatch, + js_set_native_http_dispatch, js_set_native_module_js_loader, + js_set_native_querystring_dispatch, js_set_native_sqlite_dispatch, js_set_native_tls_dispatch, + js_set_native_webcrypto_dispatch, js_set_native_zlib_dispatch, js_set_new_from_handle_v8, }; // Extension pump registration — allows extensions to register pump functions diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_d_i.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_d_i.rs index b1341d7996..8299502312 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_d_i.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_d_i.rs @@ -202,6 +202,33 @@ pub(crate) unsafe fn nm_dispatch_events(ctx: &NmCtx, module_name: &str, method_n let err = crate::error::js_typeerror_new(msg); crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) } + // Module-level helpers. Their implementations live in perry-stdlib + // (`js_events_*` in `events/module_helpers.rs`), which depends on this + // crate, so they are reachable only through the pointer perry-stdlib + // registers at startup — the zlib/querystring/domain shape. + // + // Before this arm existed, every INDIRECT form of these helpers fell to + // `_ => undefined` while the statically-dispatched call went straight to + // the same FFI and was correct: `const c = events.listenerCount; c(e,"x")` + // and `events.listenerCount(...args)` both silently answered `undefined` + // where node returns a count. The static NativeModSig rows in + // perry-codegen (`net_events.rs`, `has_receiver: false`) are the list + // this mirrors. + ( + "events", + "listenerCount" | "once" | "on" | "getEventListeners" | "getMaxListeners" + | "setMaxListeners" | "addAbortListener", + ) => { + let ptr = + crate::value::JS_NATIVE_EVENTS_DISPATCH.load(std::sync::atomic::Ordering::SeqCst); + if ptr.is_null() { + f64::from_bits(JSValue::undefined().bits()) + } else { + let dispatch: unsafe extern "C" fn(*const u8, usize, *const f64, usize) -> f64 = + std::mem::transmute(ptr); + dispatch(method_name.as_ptr(), method_name.len(), args_ptr, args_len) + } + } _ => f64::from_bits(JSValue::undefined().bits()), } } diff --git a/crates/perry-runtime/src/value/handle.rs b/crates/perry-runtime/src/value/handle.rs index 5b6ed43b60..c25cea7cec 100644 --- a/crates/perry-runtime/src/value/handle.rs +++ b/crates/perry-runtime/src/value/handle.rs @@ -72,6 +72,18 @@ pub extern "C" fn js_set_native_querystring_dispatch(func: JsNativeQuerystringDi JS_NATIVE_QUERYSTRING_DISPATCH.store(func as *mut (), Ordering::SeqCst); } +/// Set the node:events MODULE-level helper dispatcher (`events.listenerCount`, +/// `events.once`, …). Registered by perry-stdlib at startup so a captured, +/// type-erased or spread-called module helper reaches the same `js_events_*` +/// FFI the static call already uses, instead of `undefined`. The `events` +/// helpers live in perry-stdlib, which depends on perry-runtime, so the +/// dispatch bucket here can only reach them through a registered pointer — +/// same shape as zlib/querystring/domain above. +#[no_mangle] +pub extern "C" fn js_set_native_events_dispatch(func: JsNativeQuerystringDispatchFn) { + JS_NATIVE_EVENTS_DISPATCH.store(func as *mut (), Ordering::SeqCst); +} + /// Set the node:sqlite module dispatcher. Registered by perry-stdlib at /// startup so captured and dynamic-imported sqlite exports reach stdlib. #[no_mangle] diff --git a/crates/perry-runtime/src/value/mod.rs b/crates/perry-runtime/src/value/mod.rs index 607afcb82d..9c47071b12 100644 --- a/crates/perry-runtime/src/value/mod.rs +++ b/crates/perry-runtime/src/value/mod.rs @@ -61,9 +61,10 @@ pub(crate) use tags::{ pub use tags::{ JS_HANDLE_CALL_METHOD, JS_HANDLE_TYPEOF, JS_NATIVE_ASYNC_HOOKS_CONSTRUCT, JS_NATIVE_CRYPTO_DISPATCH, JS_NATIVE_DOMAIN_DISPATCH, JS_NATIVE_EVENTS_CONSTRUCT, - JS_NATIVE_HTTP_DISPATCH, JS_NATIVE_MODULE_JS_LOADER, JS_NATIVE_QUERYSTRING_DISPATCH, - JS_NATIVE_SQLITE_DISPATCH, JS_NATIVE_TLS_DISPATCH, JS_NATIVE_WEBCRYPTO_DISPATCH, - JS_NATIVE_ZLIB_DISPATCH, JS_NEW_FROM_HANDLE_V8, SHORT_STRING_MAX_LEN, + JS_NATIVE_EVENTS_DISPATCH, JS_NATIVE_HTTP_DISPATCH, JS_NATIVE_MODULE_JS_LOADER, + JS_NATIVE_QUERYSTRING_DISPATCH, JS_NATIVE_SQLITE_DISPATCH, JS_NATIVE_TLS_DISPATCH, + JS_NATIVE_WEBCRYPTO_DISPATCH, JS_NATIVE_ZLIB_DISPATCH, JS_NEW_FROM_HANDLE_V8, + SHORT_STRING_MAX_LEN, }; // Crate-internal handle dispatch atomics + callback type aliases (read by @@ -87,7 +88,7 @@ pub use handle::{ js_set_handle_array_length, js_set_handle_call_method, js_set_handle_object_get_property, js_set_handle_to_string, js_set_handle_typeof, js_set_native_async_hooks_construct, js_set_native_crypto_dispatch, js_set_native_domain_dispatch, js_set_native_events_construct, - js_set_native_http_dispatch, js_set_native_module_js_loader, + js_set_native_events_dispatch, js_set_native_http_dispatch, js_set_native_module_js_loader, js_set_native_querystring_dispatch, js_set_native_sqlite_dispatch, js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, js_set_native_zlib_dispatch, js_set_new_from_handle_v8, native_module_try_js_property, diff --git a/crates/perry-runtime/src/value/tags.rs b/crates/perry-runtime/src/value/tags.rs index c2629b98ca..76e83c3c17 100644 --- a/crates/perry-runtime/src/value/tags.rs +++ b/crates/perry-runtime/src/value/tags.rs @@ -194,6 +194,10 @@ pub static JS_NATIVE_DOMAIN_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::n pub static JS_NATIVE_TLS_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); pub static JS_NATIVE_HTTP_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); pub static JS_NATIVE_EVENTS_CONSTRUCT: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); +// Module-level `events.*` helpers (`listenerCount`, `once`, `on`, +// `getEventListeners`, `get`/`setMaxListeners`, `addAbortListener`). Distinct +// from JS_NATIVE_EVENTS_CONSTRUCT above, which only serves `new`. +pub static JS_NATIVE_EVENTS_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); // Dynamic `new ()` (e.g. `new maybeGlobalAsyncLocalStorage()` // where the value came from `globalThis.AsyncLocalStorage = AsyncLocalStorage`). // Registered by perry-stdlib at startup so a bound `async_hooks.AsyncLocalStorage` / diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index 1d1b5659c6..4ebae896d5 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -663,6 +663,9 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { perry_runtime::js_set_native_querystring_dispatch( crate::querystring::js_querystring_native_dispatch, ); + // Module-level `events.*` helpers reached indirectly (captured value, + // type-erased receiver, spread call) — see `js_events_native_dispatch`. + perry_runtime::js_set_native_events_dispatch(crate::events::js_events_native_dispatch); #[cfg(feature = "database-sqlite")] perry_runtime::js_set_native_sqlite_dispatch(crate::sqlite::js_node_sqlite_native_dispatch); perry_runtime::js_set_native_domain_dispatch(crate::domain::js_domain_native_dispatch); diff --git a/crates/perry-stdlib/src/events.rs b/crates/perry-stdlib/src/events.rs index 49df773d6d..7d10e6141b 100644 --- a/crates/perry-stdlib/src/events.rs +++ b/crates/perry-stdlib/src/events.rs @@ -58,7 +58,8 @@ pub use events_on::js_events_on; mod module_helpers; pub use module_helpers::{ js_events_add_abort_listener, js_events_get_event_listeners, js_events_get_max_listeners, - js_events_init, js_events_listener_count, js_events_set_max_listeners, + js_events_init, js_events_listener_count, js_events_native_dispatch, + js_events_set_max_listeners, }; const TAG_FALSE_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0003); diff --git a/crates/perry-stdlib/src/events/module_helpers.rs b/crates/perry-stdlib/src/events/module_helpers.rs index b1e72c9468..53b87a3e5f 100644 --- a/crates/perry-stdlib/src/events/module_helpers.rs +++ b/crates/perry-stdlib/src/events/module_helpers.rs @@ -228,3 +228,101 @@ pub unsafe extern "C" fn js_events_set_max_listeners( pub extern "C" fn js_events_init() -> f64 { undefined_value() } + +/// Coerce a NaN-boxed event-name argument to a heap string, the way the static +/// call's `NA_STR` marshalling does. A non-string goes through `ToString` +/// (Node keys listeners by the coerced name), and a missing argument becomes +/// `"undefined"` rather than a null pointer, so the helpers below see exactly +/// what the static path would hand them. +unsafe fn event_name_header(value: f64) -> *const StringHeader { + let materialized = perry_runtime::string::js_string_materialize_to_heap(value); + if !materialized.is_null() { + return materialized; + } + perry_runtime::value::js_jsvalue_to_string(value) +} + +/// Runtime bridge for the module-level `events.*` helpers reached INDIRECTLY — +/// a captured value (`const c = events.listenerCount; c(e, "x")`), a type-erased +/// receiver (`(events as any).listenerCount(e, "x")`), or a spread call +/// (`events.listenerCount(...args)`). +/// +/// All of these route through `dispatch_native_module_method` → +/// `nm_dispatch_events` in perry-runtime, which cannot name these functions +/// (perry-stdlib depends on perry-runtime, not the reverse) and so answered +/// `undefined` for every one of them while the statically dispatched call went +/// straight to the same FFI and was correct. Registered in +/// `common/dispatch/init.rs`; mirrors the zlib / querystring / domain bridges. +/// +/// The name list must stay in sync with the `has_receiver: false` `events` rows +/// of perry-codegen's `NativeModSig` table (`lower_call/native_table/net_events.rs`) +/// and the arm in `nm_dispatch_events` that calls this. `init` and +/// `EventEmitterAsyncResource` are deliberately absent — perry-runtime answers +/// those itself without needing stdlib. +#[no_mangle] +pub unsafe extern "C" fn js_events_native_dispatch( + method: *const u8, + method_len: usize, + args: *const f64, + args_len: usize, +) -> f64 { + let undefined = f64::from_bits(TAG_UNDEFINED_F64_BITS); + if method.is_null() || method_len == 0 { + return undefined; + } + let name = std::str::from_utf8(std::slice::from_raw_parts(method, method_len)).unwrap_or(""); + let arg = |i: usize| -> f64 { + if i < args_len && !args.is_null() { + *args.add(i) + } else { + undefined + } + }; + match name { + "listenerCount" => js_events_listener_count(arg(0), event_name_header(arg(1))), + "getMaxListeners" => js_events_get_max_listeners(arg(0)), + "getEventListeners" => { + let arr = js_events_get_event_listeners(arg(0), event_name_header(arg(1))); + if arr.is_null() { + undefined + } else { + js_nanbox_pointer(arr as i64) + } + } + "once" => { + let promise = crate::events::js_events_once(arg(0), event_name_header(arg(1)), arg(2)); + if promise.is_null() { + undefined + } else { + js_nanbox_pointer(promise as i64) + } + } + "on" => { + let iter = crate::events::js_events_on(arg(0), event_name_header(arg(1)), arg(2)); + if iter.is_null() { + undefined + } else { + js_nanbox_pointer(iter as i64) + } + } + "addAbortListener" => { + let disposable = js_events_add_abort_listener(arg(0), arg(1)); + if disposable == 0 { + undefined + } else { + js_nanbox_pointer(disposable) + } + } + // `setMaxListeners(n, ...targets)`: the static path hands the trailing + // targets over as ONE Perry array (`NA_VARARGS`), so rebuild that array + // from the remaining arguments rather than passing them positionally. + "setMaxListeners" => { + let mut targets = js_array_alloc(args_len.saturating_sub(1) as u32); + for i in 1..args_len { + targets = perry_runtime::array::js_array_push_f64(targets, arg(i)); + } + js_events_set_max_listeners(arg(0), targets) + } + _ => undefined, + } +} diff --git a/test-parity/node-suite/events/listeners/module-helper-dynamic-dispatch.ts b/test-parity/node-suite/events/listeners/module-helper-dynamic-dispatch.ts new file mode 100644 index 0000000000..c20e23d8c6 --- /dev/null +++ b/test-parity/node-suite/events/listeners/module-helper-dynamic-dispatch.ts @@ -0,0 +1,64 @@ +// The module-level `events.*` helpers reached INDIRECTLY. `nm_dispatch_events` +// had arms only for `init` and `EventEmitterAsyncResource`, so a captured value, +// a type-erased receiver or a spread call fell through to `undefined` while the +// statically dispatched form went straight to the same FFI and was correct — +// `events.listenerCount(e, "x")` returned 2 and `const c = events.listenerCount; +// c(e, "x")` returned `undefined`. +import events, { EventEmitter } from "node:events"; + +const e = new EventEmitter(); +e.on("x", () => {}); +e.on("x", () => {}); +e.on("y", () => {}); + +const dyn: any = events; +const captured = events.listenerCount; +const countArgs: [EventEmitter, string] = [e, "x"]; + +console.log("listenerCount static:", events.listenerCount(e, "x")); +console.log("listenerCount captured:", (captured as any)(e, "x")); +console.log("listenerCount dynamic:", dyn.listenerCount(e, "x")); +console.log("listenerCount spread:", events.listenerCount(...countArgs)); +console.log("listenerCount other event:", dyn.listenerCount(e, "y")); +console.log("listenerCount absent event:", dyn.listenerCount(e, "nope")); + +console.log("getEventListeners static:", events.getEventListeners(e, "x").length); +console.log("getEventListeners dynamic:", dyn.getEventListeners(e, "x").length); + +console.log("getMaxListeners default:", events.getMaxListeners(e)); +dyn.setMaxListeners(15, e); +console.log("after dynamic setMaxListeners:", dyn.getMaxListeners(e)); +events.setMaxListeners(...([21, e] as [number, EventEmitter])); +console.log("after spread setMaxListeners:", events.getMaxListeners(e)); + +// A name with no arm must stay `undefined` rather than becoming a hard throw. +console.log("unknown helper:", String(dyn.definitelyNotAnEventsHelper)); + +async function asyncForms() { + const a = new EventEmitter(); + setTimeout(() => a.emit("ping", 42), 5); + console.log("once dynamic:", JSON.stringify(await dyn.once(a, "ping"))); + + const b = new EventEmitter(); + setTimeout(() => b.emit("ping", 7), 5); + console.log( + "once spread:", + JSON.stringify(await events.once(...([b, "ping"] as [EventEmitter, string]))), + ); + + // `events.on` is routed by the same bridge, but its async ITERATION is a + // separate pre-existing gap — `for await (const v of events.on(e, "tick"))` + // drops its first value in the STATIC form too, on a tree without any of + // this change (`events/on/async-iterator-abort` and `events/on/validation` + // are already red for the same reason). Asserting it here would test that + // bug, not this one. `typeof` is all this fixture can honestly claim. + const c = new EventEmitter(); + console.log("on dynamic returns object:", typeof dyn.on(c, "tick")); + + const ac = new AbortController(); + const disposable = dyn.addAbortListener(ac.signal, () => console.log("abort listener fired")); + console.log("addAbortListener dynamic typeof:", typeof disposable); + ac.abort(); +} + +asyncForms(); From 187d1370e00ed2419bcfe1faa9bf74236a78ccff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 08:08:55 +0200 Subject: [PATCH 2/3] docs(changelog): fragment for #7745 --- ...5-events-module-helper-dynamic-dispatch.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 changelog.d/7745-events-module-helper-dynamic-dispatch.md diff --git a/changelog.d/7745-events-module-helper-dynamic-dispatch.md b/changelog.d/7745-events-module-helper-dynamic-dispatch.md new file mode 100644 index 0000000000..e8f2b9859e --- /dev/null +++ b/changelog.d/7745-events-module-helper-dynamic-dispatch.md @@ -0,0 +1,34 @@ +Fixed the module-level `node:events` helpers returning `undefined` whenever they +were reached INDIRECTLY. `events.listenerCount(e, "x")` was correct, but +`const c = events.listenerCount; c(e, "x")`, `(events as any).listenerCount(e, +"x")` and `events.listenerCount(...args)` all silently answered `undefined`. + +`nm_dispatch_events` had arms for exactly two names — `init` and +`EventEmitterAsyncResource` — and everything else fell to `_ => undefined`. The +seven module-level helpers (`listenerCount`, `once`, `on`, `getEventListeners`, +`getMaxListeners`, `setMaxListeners`, `addAbortListener`) are implemented in +perry-stdlib, which depends on perry-runtime, so the dispatch bucket cannot name +them; the static call reaches them directly through codegen's `NativeModSig` +rows, which is why only the indirect forms were dead. Added the +registered-pointer bridge every comparable module already has (zlib / +querystring / domain): `JS_NATIVE_EVENTS_DISPATCH` + +`js_set_native_events_dispatch` in perry-runtime and `js_events_native_dispatch` +in perry-stdlib, wired at `js_stdlib_init_dispatch`. Argument marshalling matches +the static path's rows — event names go through `ToString`, and +`setMaxListeners(n, ...targets)` rebuilds its trailing targets into the single +array the helper expects. Distinct from the existing `JS_NATIVE_EVENTS_CONSTRUCT`, +which serves only `new`. + +Follow-up to #7734, which named this as the one remaining wrong-to-wrong case in +the #7720 spread-call matrix: `events.listenerCount(...args)` turned a bogus +`ERR_INVALID_ARG_TYPE` throw into `undefined`. It now returns the count. + +`events_dispatch_parity_tests` walks the real `NET_EVENTS_ROWS` table and fails +on any `has_receiver: false` `events` row that is not classified as +routed-to-stdlib, answered-by-runtime, or deliberately-unrouted — the drift that +caused this bug — with a second test rejecting stale entries so the +classification cannot rot. `events.on`'s async ITERATION is deliberately not +asserted: it drops its first value in the static form too, on a tree without this +change (`events/on/async-iterator-abort` and `events/on/validation` are already +red for it); the helper is routed regardless, so it inherits the fix when that +gap closes. From d5da0afaa854496208a52e3c59b009968a777f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 08:22:25 +0200 Subject: [PATCH 3/3] chore: bump version to 0.5.1435 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0230db5fbb..dea59726be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1434 +**Current Version:** 0.5.1435 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 34362ea65b..43293cd591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1434" +version = "0.5.1435" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1434" +version = "0.5.1435" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1434" +version = "0.5.1435" [[package]] name = "perry-ui-tvos" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1434" +version = "0.5.1435" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 43a0760ca1..d093e6d901 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1434" +version = "0.5.1435" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"