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
24 changes: 24 additions & 0 deletions crates/perry-runtime/src/closure/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1332,6 +1332,30 @@ pub unsafe extern "C" fn js_native_call_value(
return crate::proxy::js_proxy_apply(func_value, this_arg, arr_box);
}

// Dynamic `super()` for `class X extends <runtime value holding
// events.EventEmitter>` (an import alias `import { EventEmitter as E }` or a
// local `const E = EventEmitter`): the parent is a bound-native EventEmitter
// export reached through a runtime value, so codegen's compile-time
// extends-NAME machinery — which emits `js_event_emitter_subclass_init` for
// 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 IMPLICIT_THIS bound to 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.
if let Some((module, method)) =
unsafe { crate::object::bound_native_callable_module_and_method(func_value) }
{
if module.trim_start_matches("node:") == "events"
&& (method == "EventEmitter" || method == "EventEmitterAsyncResource")
{
let this_val = crate::object::js_implicit_this_get();
if JSValue::from_bits(this_val.to_bits()).is_pointer() {
return crate::node_stream::js_event_emitter_subclass_init(this_val);
}
}
}

// Get the closure pointer from the value
// For native compilation, function values are stored as NaN-boxed pointers
let closure: *const ClosureHeader = if jsval.is_pointer() {
Expand Down
144 changes: 144 additions & 0 deletions crates/perry/tests/issue_event_emitter_aliased_extends.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//! `class X extends <runtime value holding events.EventEmitter>` — an import
//! alias (`import { EventEmitter as E } from "events"`) or a local
//! `const E = EventEmitter` — must inherit the EventEmitter instance methods
//! (`setMaxListeners`/`on`/`emit`/`getMaxListeners`/…), exactly like the direct
//! `class X extends EventEmitter` form.
//!
//! The direct form lowers through codegen's compile-time extends-NAME machinery
//! (which emits `js_event_emitter_subclass_init` in `super()`). The aliased /
//! indirect form has a runtime-VALUE parent, so codegen lowers `super()` through
//! the dynamic-parent path — `js_native_call_value(parentValue, …)` with
//! IMPLICIT_THIS bound to the fresh instance — and
//! `js_register_class_parent_dynamic` early-returns for bound-native parents.
//! Before the fix, `super()` installed nothing and `this.setMaxListeners(0)` in
//! the subclass constructor threw `TypeError: value is not a function`.
//!
//! Fix: `js_native_call_value` detects a bound-native `events.EventEmitter`
//! invoked with IMPLICIT_THIS set (the dynamic-`super()` shape) and installs the
//! EventEmitter methods onto the receiver instance, mirroring the direct form.

use std::path::PathBuf;
use std::process::Command;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

fn compile_and_run(dir: &std::path::Path, entry: &std::path::Path) -> (bool, String) {
let output = dir.join("main_bin");
let compile = Command::new(perry_bin())
.current_dir(dir)
.arg("compile")
.arg(entry)
.arg("-o")
.arg(&output)
.output()
.expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);
let run = Command::new(&output).output().expect("run compiled binary");
(
run.status.success(),
String::from_utf8_lossy(&run.stdout).to_string(),
)
}

#[test]
fn aliased_import_extends_eventemitter_inherits_methods() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
std::fs::write(
&entry,
r#"
// Import alias — the common minified shape (`import { EventEmitter as nL_ }`).
import { EventEmitter as EE } from "events"

// Named class expression assigned to a var, with an `emit` override that calls
// `super.emit` — mirrors ink's internal event emitter, the real-world trigger.
let M6: any
M6 = class M6 extends EE {
constructor() {
super()
this.setMaxListeners(0) // must NOT throw "value is not a function"
}
emit(event: any, ...args: any[]) {
if (event === "error") return super.emit(event, ...args)
return super.emit(event, ...args)
}
}

const m: any = new M6()
console.log("ctor-ok")
console.log("setMaxListeners:", typeof m.setMaxListeners)
console.log("getMaxListeners:", typeof m.getMaxListeners, m.getMaxListeners())
let payload = ""
m.on("evt", (p: string) => { payload = p })
m.emit("evt", "PAYLOAD")
console.log("dispatched:", payload)
console.log("DONE")
"#,
)
.expect("write entry");

let (ok, stdout) = compile_and_run(dir.path(), &entry);
assert!(ok, "binary failed\nstdout:\n{stdout}");
for needle in [
"ctor-ok",
"setMaxListeners: function",
"getMaxListeners: function 0",
"dispatched: PAYLOAD",
"DONE",
] {
assert!(
stdout.contains(needle),
"expected `{needle}` in output:\n{stdout}"
);
}
}

#[test]
fn local_const_alias_extends_eventemitter_inherits_methods() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
std::fs::write(
&entry,
r#"
// A local indirection (not an import alias) must behave identically: the parent
// is still a runtime VALUE holding the bound-native EventEmitter export.
import { EventEmitter } from "events"
const Base = EventEmitter
class Widget extends Base {
constructor() { super(); this.setMaxListeners(11) }
}
const w: any = new Widget()
console.log("ctor-ok")
console.log("setMaxListeners:", typeof w.setMaxListeners)
console.log("maxListeners:", w.getMaxListeners())
let hit = false
w.on("ping", () => { hit = true })
w.emit("ping")
console.log("dispatched:", hit)
console.log("DONE")
"#,
)
.expect("write entry");

let (ok, stdout) = compile_and_run(dir.path(), &entry);
assert!(ok, "binary failed\nstdout:\n{stdout}");
for needle in [
"ctor-ok",
"setMaxListeners: function",
"maxListeners: 11",
"dispatched: true",
"DONE",
] {
assert!(
stdout.contains(needle),
"expected `{needle}` in output:\n{stdout}"
);
}
}
Loading