From 5c738b636975f0b90e6418da357ea80a6d6a6af5 Mon Sep 17 00:00:00 2001 From: Ralph Date: Mon, 15 Jun 2026 02:17:28 -0700 Subject: [PATCH] fix(link): route emitted js_event_emitter_* symbols to perry-ext-events (#5140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new EventEmitter()` / `.on` / `.emit` / `.removeAllListeners` lower to `js_event_emitter_*` helpers purely off the class NAME being `EventEmitter` (`lower_call/builtin.rs` + the events native-table rows), regardless of which package the binding came from. The canonical implementations live in `perry-ext-events` — the `[bindings.events]` well-known crate — and `import "events"` flips that wrapper onto the link line via `ctx.native_module_imports`. But a program that gets `EventEmitter` from a *different* package — e.g. `import EventEmitter from "eventemitter3"` under `perry.compilePackages` — never inserts `"events"` into the import set, so the wrapper stays off the link line and the build fails with: Undefined symbols for architecture arm64: "_js_event_emitter_emit" "_js_event_emitter_new_with_options" "_js_event_emitter_on" "_js_event_emitter_remove_all_listeners" Tag the emitted core EventEmitter symbols in the codegen FFI provenance registry as `WellKnown("events")` so the well-known flip fires off codegen provenance instead of imports — the same mechanism the #846 / #3954 http/net rows already use. Flipping `"events"` links perry-ext-events, strips the duplicate `bundled-events` stdlib feature, and activates `external-events-construct` (the default-import dynamic-new path, #4995). Verified: `import EventEmitter from "eventemitter3"; …` now links and prints `5` (matching Node); `import { EventEmitter } from "events"` still prints `5`. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-codegen/src/ext_registry.rs | 77 ++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index c86e6a98dc..eddb8f2560 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -462,6 +462,47 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ ("js_url_create_object_url", OwnerKind::Stdlib { feature: Some("http-client") }), ("js_url_revoke_object_url", OwnerKind::Stdlib { feature: Some("http-client") }), ("js_buffer_resolve_object_url", OwnerKind::Stdlib { feature: Some("http-client") }), + + // ── #5140: EventEmitter without a source-level `import "events"` ── + // `new EventEmitter()` / `.on` / `.emit` / … lower to these + // `js_event_emitter_*` helpers purely off the class NAME being + // `EventEmitter` (`lower_call/builtin.rs` + the events native-table + // rows), regardless of which package the binding came from. The + // canonical implementations live in `perry-ext-events` (the + // `[bindings.events]` well-known crate); `import "events"` flips that + // wrapper onto the link line via `ctx.native_module_imports`. But a + // program that gets `EventEmitter` from a *different* package — e.g. + // `import EventEmitter from "eventemitter3"` under + // `perry.compilePackages` — never inserts `"events"` into the import + // set, so the wrapper stays off the link line and the build fails with + // `Undefined symbols: _js_event_emitter_new_with_options` (and the + // `_on` / `_emit` / `_remove_all_listeners` companions). Tagging the + // emitted symbols here makes the well-known flip fire off codegen + // provenance instead of imports — same mechanism as the #846 / #3954 + // http/net rows above. Flipping `"events"` also activates perry-stdlib's + // `external-events-construct` feature (see optimized_libs.rs), which the + // default-import dynamic-`new` path relies on (#4995). + // + // Only the core surface defined by perry-ext-events is listed; the + // `js_event_emitter_async_resource_*` helpers live in perry-stdlib and + // are out of scope here (`EventEmitterAsyncResource` is node:events-only). + ("js_event_emitter_new", OwnerKind::WellKnown("events")), + ("js_event_emitter_new_with_options", OwnerKind::WellKnown("events")), + ("js_event_emitter_on", OwnerKind::WellKnown("events")), + ("js_event_emitter_once", OwnerKind::WellKnown("events")), + ("js_event_emitter_prepend_listener", OwnerKind::WellKnown("events")), + ("js_event_emitter_prepend_once_listener", OwnerKind::WellKnown("events")), + ("js_event_emitter_emit", OwnerKind::WellKnown("events")), + ("js_event_emitter_emit0", OwnerKind::WellKnown("events")), + ("js_event_emitter_remove_listener", OwnerKind::WellKnown("events")), + ("js_event_emitter_remove_all_listeners", OwnerKind::WellKnown("events")), + ("js_event_emitter_listener_count", OwnerKind::WellKnown("events")), + ("js_event_emitter_listeners", OwnerKind::WellKnown("events")), + ("js_event_emitter_raw_listeners", OwnerKind::WellKnown("events")), + ("js_event_emitter_event_names", OwnerKind::WellKnown("events")), + ("js_event_emitter_set_max_listeners", OwnerKind::WellKnown("events")), + ("js_event_emitter_get_max_listeners", OwnerKind::WellKnown("events")), + ("js_event_emitter_domain_value", OwnerKind::WellKnown("events")), ]; /// Process-wide collector of provider keys observed during codegen. @@ -632,6 +673,42 @@ mod tests { } } + /// #5140 regression: `new EventEmitter()` / `.on` / `.emit` / + /// `.removeAllListeners` lower to `js_event_emitter_*` helpers off the + /// class name alone, so a program that imports `EventEmitter` from a + /// non-`events` package (`import EventEmitter from "eventemitter3"`) + /// emits these symbols without ever inserting `"events"` into the import + /// set. Each must route to `WellKnown("events")` so the well-known flip + /// pulls `perry-ext-events` onto the link line; before the fix the build + /// failed with `Undefined symbols: _js_event_emitter_new_with_options`. + #[test] + fn emitted_event_emitter_symbols_route_to_events() { + let _guard = PROVIDER_TEST_LOCK + .lock() + .expect("provider test lock poisoned"); + for symbol in [ + "js_event_emitter_new", + "js_event_emitter_new_with_options", + "js_event_emitter_on", + "js_event_emitter_once", + "js_event_emitter_prepend_listener", + "js_event_emitter_prepend_once_listener", + "js_event_emitter_emit", + "js_event_emitter_emit0", + "js_event_emitter_remove_listener", + "js_event_emitter_remove_all_listeners", + "js_event_emitter_listener_count", + "js_event_emitter_listeners", + "js_event_emitter_raw_listeners", + "js_event_emitter_event_names", + "js_event_emitter_set_max_listeners", + "js_event_emitter_get_max_listeners", + "js_event_emitter_domain_value", + ] { + assert_symbol_routes_to(symbol, OwnerKind::WellKnown("events")); + } + } + /// #2013 regression: net validation fixtures emit provider-only /// `perry-ext-net` symbols through native-table lowering. Each one must /// route to the net well-known archive so `PERRY_NO_AUTO_OPTIMIZE=1`