diff --git a/docs/content/limitations/builtin-apis/eventemitter.mdx b/docs/content/limitations/builtin-apis/eventemitter.mdx index 0e085c56f..e6c140cde 100644 --- a/docs/content/limitations/builtin-apis/eventemitter.mdx +++ b/docs/content/limitations/builtin-apis/eventemitter.mdx @@ -5,6 +5,9 @@ An untyped `emit` of an event that has a listener with typed parameters converts its arguments to the event's typed tuple before any listener runs, and throws a `TypeError` when they do not convert. Through an untyped value, `listeners()` and `rawListeners()` of an event with a listener that has typed parameters are unsupported, and `emit('error')` of an object that is not an Error with no `error` listener is unsupported. Untyped `newListener` and `removeListener` listeners receive `undefined` in place of a listener registered with typed parameters. - Reading EventEmitter methods as properties through an untyped value (`typeof emitter.listeners`) answers `undefined`. + An untyped `emit` on, or an untyped read of `emit` from, an instance of a class that overrides `emit` is unsupported. + Calling `EventEmitter.prototype.emit` as a function value on a compiled emitter with more than 16 arguments after the event name is unsupported. + Calling EventEmitter.prototype's methods with an instance of a compiled class that does not extend EventEmitter as the receiver throws a `TypeError`. + Through an untyped value, a stream's `emit` and listener-introspection methods read as `undefined`. `events.once` accepts EventEmitter and stream instances with a literal event name and an optional object-literal `{ signal }`; a stream's `data` event, other options values, and EventTarget emitters are unsupported. Reading `defaultMaxListeners` through a CommonJS destructured or member binding (`const { defaultMaxListeners } = require("events")`) is unsupported. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 007c78c9e..3f071d27c 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1465,6 +1465,7 @@ export const LIB_FN_SYMS: Record = { "dyn.classInherit": "scr_dyn_class_inherit", "dyn.builtinClass": "scr_dyn_builtin_class", "dyn.builtinStreamClass": "scr_dyn_builtin_stream_class", + "dyn.emitterMethod": "scr_dyn_emitter_method", "dyn.builtinInstanceOf": "scr_dyn_builtin_instanceof", "dyn.functionInstanceOf": "scr_dyn_function_instanceof", "dyn.classSuper": "scr_dyn_class_super", diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index c9e6768a6..af2a61213 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -74,6 +74,12 @@ import { lowerDynamicEmitterOn, } from "./lower-event-emitter.js"; import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js"; +import { + EMITTER_PROTOTYPE_METHODS, + emitterMethodValue, + inheritedEmitterMethod, + overriddenEmit, +} from "./emitter-method-values.js"; import { STREAM_OPERATORS } from "./lower-stream.js"; import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js"; @@ -2771,6 +2777,7 @@ export class ClassDynamicDispatch { !isClassOwnEnumerableFieldName(dispatch.name) || (!(info.builtinStream !== undefined && streamProperty(dispatch.name)) && !eventTargetInheritedMember(info, dispatch.name) && + !inheritedEmitterMethod(lowerer, info, dispatch.name) && !findMethodOn(lowerer, info, dispatch.name) && !findGenericMethodOn(lowerer, info, dispatch.name) && dispatch.name !== "constructor" && @@ -2889,6 +2896,12 @@ export class ClassDynamicDispatch { loc, }, ]; + // An inherited EventEmitter.prototype method: `in` sees it, the + // own-property probes see only an own property that shadows it. + if (dispatch.probe === "in" && inheritedEmitterMethod(lowerer, info, name)) + return [ + { kind: "return", value: { kind: "boolLit", value: true, type: BOOL, loc }, loc }, + ]; if (!dispatch.probe) return dispatch.reflect ? this.reflectPropertyBody(lowerer, { ...dispatch, name }, info, receiver) @@ -3101,6 +3114,8 @@ export class ClassDynamicDispatch { for (const name of [...STREAM_BOOL_PROPERTIES, ...STREAM_NUM_PROPERTIES]) names.add(name); const inherited = eventTargetApiMembers(info); if (inherited) for (const name of inherited) names.add(name); + for (const name of EMITTER_PROTOTYPE_METHODS.keys()) + if (inheritedEmitterMethod(lowerer, info, name)) names.add(name); for (let owner: ClassInfo | null = info; owner; owner = owner.base) { const methodNames = [...owner.methods.keys()]; @@ -3548,6 +3563,10 @@ export class ClassDynamicDispatch { for (const name of c.methods.keys()) names.add(name.startsWith("get:") || name.startsWith("set:") ? name.slice(4) : name); } + // EventEmitter.prototype's methods on compiled emitters. + for (const { info } of this.propertyReceivers(lowerer, true)) + for (const name of EMITTER_PROTOTYPE_METHODS.keys()) + if (inheritedEmitterMethod(lowerer, info, name)) names.add(name); return names; } @@ -4308,7 +4327,9 @@ export class ClassDynamicDispatch { (!canonical || !seen.has(info.def.name)) && (info === capsule || lowerer.classCanBeConstructed(info)) ) { - seen.add(info.def.name); + // A subclass visited under a runtime capsule (boxed as that + // base) keeps its own canonical plan for values boxed as itself. + if (canonical) seen.add(info.def.name); plans.push({ info, capsule: canonical ? info : capsule, @@ -4728,6 +4749,45 @@ export class ClassDynamicDispatch { loc, }, ]; + // An emit override has no function value (it is specialized per event + // name and payload type). + if ( + !write && + name === "emit" && + overriddenEmit(info) && + inheritedEmitterMethod(lowerer, info, name) + ) + return [ + { + kind: "runtimeFence", + code: "SC2020", + message: + "reading an overridden EventEmitter emit through an untyped value is not supported yet", + loc, + }, + ]; + // An inherited EventEmitter.prototype method: an own property shadows + // it, otherwise the read answers the function + // `EventEmitter.prototype.` reads. + if (!write && inheritedEmitterMethod(lowerer, info, name)) { + const instance = lowerer.coerceToExpected(receiver, DYN); + const bag = this.readPropertyBagOrEmpty(lowerer, instance, loc); + const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; + return [ + { + kind: "return", + value: { + kind: "ternary", + cond: this.readBagHas(lowerer, "dyn.hasKey", bag, key, loc), + then: { kind: "libCall", fn: "dyn.bagGet", args: [bag, key, instance], type: DYN, loc }, + else_: emitterMethodValue(lowerer, name, loc), + type: DYN, + loc, + }, + loc, + }, + ]; + } const field = info.fields.get(name); const member = `${write ? "set" : "get"}:${name}`; const accessor = field ? null : findMethodOn(lowerer, info, member); @@ -5159,6 +5219,11 @@ export class ClassDynamicDispatch { loc, }, ]; + // An emit override is specialized per event name and payload type; an + // untyped emit names neither, so it refuses rather than bypassing the + // override for the native emit. + if (method === "emit" && overriddenEmit(info) && inheritedEmitterMethod(lowerer, info, method)) + return fence(); if ( emitterRooted(lowerer, info) && !streamRooted(info) && diff --git a/packages/compiler/src/frontend/lowering/emitter-method-values.ts b/packages/compiler/src/frontend/lowering/emitter-method-values.ts new file mode 100644 index 000000000..f04957e11 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/emitter-method-values.ts @@ -0,0 +1,303 @@ +/** + * EventEmitter.prototype's methods as values. A read of `o.on` on a + * compiled emitter (a program class extending EventEmitter, or + * `new EventEmitter()`) through an untyped value or as a value, and + * `EventEmitter.prototype.on` itself, answer the runtime's + * EventEmitter.prototype functions (scr_dyn_builtin_class.c), the same + * functions JavaScript constructor functions inherit: `on` and + * `addListener` are one function, as are `off` and `removeListener`, and + * each carries Node's `name` and `length`. Called on a compiled emitter + * (`f.call(o, …)`, `f.bind(o)`, `f.apply(o, …)`), such a function hands + * the call to the program's dispatch below, which invokes the method on + * the receiver through the same untyped dispatch a direct `o.on(…)` call + * reaches. + */ +import { BOOL, DYN, F64, STRING, type IrExpr, type IrStmt, type SrcLoc } from "../../ir/ir.js"; +import { dynUndefinedExpr, strLit, varRef } from "../../ir/build.js"; +import * as ts from "../ts7/adapter.js"; +import { locOf } from "../program.js"; +import type { Lowerer } from "./lowerer.js"; +import { findGenericMethodOn, findMethodOn, type ClassInfo } from "./lower-classes.js"; + +/** EventEmitter.prototype's method names. */ +export const EMITTER_PROTOTYPE_METHODS: ReadonlySet = new Set([ + "setMaxListeners", + "getMaxListeners", + "emit", + "addListener", + "on", + "prependListener", + "once", + "prependOnceListener", + "removeListener", + "off", + "removeAllListeners", + "listeners", + "rawListeners", + "listenerCount", + "eventNames", +]); + +/** `emit` forwards this many arguments after the event name; a call with + * more fences. */ +const EMIT_ARGUMENTS = 16; + +/** The runtime's SCR_EE_M_* order: each method's name and the argument + * counts its call forwards. Node's methods that look at + * `arguments.length` (removeAllListeners, emit, listenerCount's optional + * listener) get exactly the arguments given; the rest get their declared + * parameters, an absent one as undefined. */ +const DISPATCHED: readonly { + method: string; + fixed?: number; + counts?: (given: number) => number; +}[] = [ + { method: "addListener", fixed: 2 }, + { method: "prependListener", fixed: 2 }, + { method: "once", fixed: 2 }, + { method: "prependOnceListener", fixed: 2 }, + { method: "removeListener", fixed: 2 }, + { method: "removeAllListeners", counts: (given) => Math.min(given, 1) }, + { method: "emit", counts: (given) => Math.max(given, 1) }, + { method: "listeners", fixed: 1 }, + { method: "rawListeners", fixed: 1 }, + { method: "listenerCount", counts: (given) => (given < 2 ? 1 : 2) }, + { method: "eventNames", fixed: 0 }, + { method: "setMaxListeners", fixed: 1 }, + { method: "getMaxListeners", fixed: 0 }, +]; + +const DISPATCH = "%emitter.method.dispatch"; + +/** An EventEmitter.prototype method a non-stream emitter class inherits + * (no field, method or accessor of that name in its chain; streams keep + * their own surface). An `emit` override is not a method, so `emit` counts + * here too; overriddenEmit separates it. */ +export function inheritedEmitterMethod(lowerer: Lowerer, info: ClassInfo, name: string): boolean { + if (!EMITTER_PROTOTYPE_METHODS.has(name)) return false; + let emitter = false; + for (let c: ClassInfo | null = info; c; c = c.base) { + if (c.builtinStream !== undefined) return false; + if (c.builtinEmitter) emitter = true; + } + return ( + emitter && + !info.fields.has(name) && + !findMethodOn(lowerer, info, name) && + !findGenericMethodOn(lowerer, info, name) && + !findMethodOn(lowerer, info, `get:${name}`) && + !findMethodOn(lowerer, info, `set:${name}`) + ); +} + +/** True when the class or an ancestor overrides `emit` (lowered as + * per-event specializations rather than a method). */ +export function overriddenEmit(info: ClassInfo): boolean { + for (let c: ClassInfo | null = info; c; c = c.base) if (c.emitOverride) return true; + return false; +} + +/** A typed read of an inherited EventEmitter.prototype method as a value + * (`const f = emitter.on`, `typeof emitter.emit`) on a compiled emitter or + * a union of them: the receiver is evaluated, the read answers the + * interned method value. */ +export function lowerEmitterInstanceMethod( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + const name = expr.name.text; + if (!EMITTER_PROTOTYPE_METHODS.has(name) || expr.questionDotToken) return null; + if (ts.isCallExpression(expr.parent) && expr.parent.expression === expr) return null; + const symbol = lowerer.checker.getSymbolAtLocation(expr.name); + if (!symbol || !lowerer.isStdlibSymbol(symbol)) return null; + const receiverType = lowerer.mapTypeOf(lowerer.typeOf(expr.expression)); + if (!receiverType) return null; + const arms = + receiverType.kind === "union" + ? (lowerer.unions.get(receiverType.unionId)?.arms ?? []) + : [receiverType]; + if ( + !arms.length || + !arms.every((arm) => { + const info = arm.kind === "object" ? lowerer.classes.get(arm.className) : undefined; + return info !== undefined && inheritedEmitterMethod(lowerer, info, name); + }) + ) + return null; + const loc = locOf(expr); + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: lowerer.lowerExpr(expr.expression), loc }], + result: emitterMethodValue(lowerer, name, loc), + type: DYN, + loc, + }; +} + +/** The program's dispatch for EventEmitter.prototype's functions on + * compiled emitters: `this` is the emitter, the first argument the + * method's index, the rest the call's arguments. */ +function emitterMethodDispatch(lowerer: Lowerer, loc: SrcLoc): IrExpr { + if (!lowerer.liftedFns.some((fn) => fn.name === DISPATCH)) { + const self = varRef("self", DYN, loc); + const values = varRef("values", DYN, loc); + const index = varRef("index", F64, loc); + const given = varRef("given", F64, loc); + const number = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); + const equals = (left: IrExpr, value: number): IrExpr => ({ + kind: "bin", + op: "===", + left, + right: number(value), + type: BOOL, + loc, + }); + const element = (i: number): IrExpr => ({ + kind: "dynKeyGet", + value: values, + key: lowerer.coerceToExpected(number(i), DYN), + type: DYN, + loc, + }); + const invoke = (method: string, n: number): IrStmt => ({ + kind: "return", + value: { + kind: "dynInvoke", + recv: self, + method, + calleeName: method, + args: Array.from({ length: n }, (_, i) => element(i + 1)), + type: DYN, + loc, + }, + loc, + }); + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: "self", + init: { kind: "libCall", fn: "dyn.this", args: [], type: DYN, loc }, + loc, + }, + { + kind: "varDecl", + localId: "index", + init: { kind: "dynCheck", value: element(0), type: F64, loc }, + loc, + }, + { + kind: "varDecl", + localId: "given", + init: { + kind: "bin", + op: "-", + left: { + kind: "dynCheck", + value: { kind: "dynKeyGet", value: values, key: strLit("length", loc), type: DYN, loc }, + type: F64, + loc, + }, + right: number(1), + type: F64, + loc, + }, + loc, + }, + ]; + DISPATCHED.forEach(({ method, fixed, counts }, i) => { + const arms: IrStmt[] = []; + if (fixed !== undefined || !counts) arms.push(invoke(method, fixed ?? 0)); + else { + // One arm per argument count up to the last one that matters. + const limit = method === "emit" ? EMIT_ARGUMENTS + 1 : 2; + for (let n = 0; n < limit; n++) + arms.push({ + kind: "if", + cond: equals(given, n), + then: [invoke(method, counts(n))], + else_: null, + loc, + }); + if (method === "emit") + arms.push( + { + kind: "if", + cond: equals(given, limit), + then: [invoke(method, limit)], + else_: null, + loc, + }, + { + kind: "runtimeFence", + code: "SC2020", + message: `calling EventEmitter.prototype.emit through a method value with more than ${EMIT_ARGUMENTS} arguments after the event name is not supported yet`, + loc, + }, + ); + else arms.push(invoke(method, counts(limit))); + } + body.push({ kind: "if", cond: equals(index, i), then: arms, else_: null, loc }); + }); + body.push({ kind: "return", value: dynUndefinedExpr(loc), loc }); + lowerer.liftedFns.push({ + name: DISPATCH, + params: [{ localId: "values", name: "values", type: DYN }], + returnType: DYN, + locals: [ + { id: "values", name: "values", type: DYN, mutable: false }, + { id: "self", name: "self", type: DYN, mutable: false }, + { id: "index", name: "index", type: F64, mutable: false }, + { id: "given", name: "given", type: F64, mutable: false }, + ], + body, + loc, + }); + } + return { + kind: "dynFrom", + value: { + kind: "closure", + fnName: DISPATCH, + captures: [], + type: { kind: "func", params: [], ret: DYN, rest: true, argumentsAll: true }, + loc, + }, + type: DYN, + loc, + }; +} + +/** EventEmitter.prototype[name] (DYN). */ +export function emitterMethodValue(lowerer: Lowerer, name: string, loc: SrcLoc): IrExpr { + return { + kind: "libCall", + fn: "dyn.emitterMethod", + args: [{ kind: "strLit", value: name, type: STRING, loc }, emitterMethodDispatch(lowerer, loc)], + type: DYN, + loc, + }; +} + +/** `EventEmitter.prototype.` (any import spelling of the class): + * the interned method value. */ +export function lowerEmitterPrototypeMethod( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + if (!EMITTER_PROTOTYPE_METHODS.has(expr.name.text)) return null; + const prototype = expr.expression; + if ( + !ts.isPropertyAccessExpression(prototype) || + prototype.questionDotToken || + prototype.name.text !== "prototype" + ) + return null; + const owner = prototype.expression; + const symbol = ts.isIdentifier(owner) + ? lowerer.resolveValueSymbol(owner) + : ts.isPropertyAccessExpression(owner) + ? lowerer.checker.getSymbolAtLocation(owner.name) + : null; + if (!lowerer.builtinEmitterInfoOf(symbol)) return null; + return emitterMethodValue(lowerer, expr.name.text, locOf(expr)); +} diff --git a/packages/compiler/src/frontend/lowering/expressions/property-access.ts b/packages/compiler/src/frontend/lowering/expressions/property-access.ts index 36ea0e8c2..1e2722114 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -128,6 +128,10 @@ import { lowerReadlineProperty } from "../builtins/readline.js"; import { lowerEventStaticProperty } from "../builtins/numbers.js"; import { lowerStreamProperty, lowerStreamStateProperty, streamSidesOf } from "../lower-stream.js"; import { lowerEventObjectProperty } from "../lower-event-target.js"; +import { + lowerEmitterInstanceMethod, + lowerEmitterPrototypeMethod, +} from "../emitter-method-values.js"; import { iteratorResultValueRead } from "../lower-generators.js"; import { lowerGlobalValue } from "../lower-global-value.js"; import { nullishGenericBindingUnitOf } from "../binding-analysis.js"; @@ -329,6 +333,12 @@ export function lowerPropertyAccessExpr( const ctor = runtimeConstructorReceiver(lowerer, expr); if (ctor) return lowerRuntimeConstructor(lowerer, expr, ctor.info); } + // EventEmitter.prototype's methods as values (emitter-method-values.ts). + { + const method = + lowerEmitterPrototypeMethod(lowerer, expr) ?? lowerEmitterInstanceMethod(lowerer, expr); + if (method) return method; + } // `C.name` through a runtime-provided class VALUE (a `typeof Readable` // parameter): the class object's stored name. if ( diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index d181927fc..9bba57167 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3611,6 +3611,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * functions over them get native stream state * (scr_dyn_builtin_stream.c). */ "dyn.builtinStreamClass": { argTypes: [F64], result: DYN }, + /** EventEmitter.prototype[name] off EventEmitter (0) above; the first + * read installs the program's dispatch (the DYN function) that runs + * those methods on compiled emitters. */ + "dyn.emitterMethod": { argTypes: [STRING, DYN], result: DYN }, /** `value instanceof C` for those constructors, over the value's * prototype chain. */ "dyn.builtinInstanceOf": { argTypes: [DYN, F64], result: BOOL }, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index fd7c98cf6..5c38b14f9 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -259,6 +259,8 @@ const BUILTIN_CLASS_LIB_FNS: readonly IrLibFn[] = [ "dyn.builtinClass", // the stream classes, through scr_dyn_builtin_stream.c "dyn.builtinStreamClass", + // EventEmitter.prototype's methods read off compiled emitters + "dyn.emitterMethod", "dyn.builtinInstanceOf", "dyn.functionInstanceOf", ]; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6600-emitter-method-values-untyped.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6600-emitter-method-values-untyped.ts.json new file mode 100644 index 000000000..260282e75 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6600-emitter-method-values-untyped.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6600-emitter-method-values-untyped.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6601-emitter-method-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6601-emitter-method-values.cjs.json new file mode 100644 index 000000000..13f1aaa7d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6601-emitter-method-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6601-emitter-method-values.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6602-emitter-method-values-typed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6602-emitter-method-values-typed.ts.json new file mode 100644 index 000000000..409f1eaa9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6602-emitter-method-values-typed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6602-emitter-method-values-typed.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 401bf2b39..14734ab31 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1159,6 +1159,11 @@ ScrDyn *scr_dyn_new_class(ScrClassObj *cls, const char *type_key); ScrDyn *scr_dyn_builtin_class(double kind); bool scr_dyn_builtin_instanceof(ScrDyn *value, double kind); bool scr_dyn_function_instanceof(ScrDyn *value, ScrDyn *callee); +/* EventEmitter.prototype[name] (+1); the first call installs DISPATCH, + * the program's function that runs those methods on compiled emitters + * (typed refs): called with the emitter as `this`, the method's + * SCR_EE_M_* index, then the call's arguments. */ +ScrDyn *scr_dyn_emitter_method(const ScrStr *name, ScrDyn *dispatch); /* The stream half of those constructors (scr_dyn_builtin_stream.c, linked * with the dynBuiltinStream feature), registered with * scr_dyn_builtin_class_streams before the first stream class value is diff --git a/packages/runtime/src/scr_dyn_builtin_class.c b/packages/runtime/src/scr_dyn_builtin_class.c index 2f947048a..70a67c559 100644 --- a/packages/runtime/src/scr_dyn_builtin_class.c +++ b/packages/runtime/src/scr_dyn_builtin_class.c @@ -66,9 +66,35 @@ static ScrDyn *bc_class(int kind); /* The stream half, once registered (scr_dyn_builtin_stream.c). */ static SCR_TL const ScrDynStreamOps *bc_stream_ops; -/* EventEmitter.prototype's method on a receiver with stream state. */ +/* The program's dispatch for compiled emitters (a program class extending + * EventEmitter, or `new EventEmitter()`), which reach these methods as + * typed refs whose listeners live in the native registry; installed by + * scr_dyn_emitter_method. */ +static SCR_TL ScrDyn *bc_compiled_dispatch; + +/* method(...args) on a compiled emitter: the dispatch runs with the + * receiver as `this` and the method's SCR_EE_M_* index before the + * arguments. */ +static ScrDyn *bc_compiled_event(int method, ScrDyn *self, ScrDyn *const *args, size_t argc) { + ScrDyn **argv = malloc((argc + 1) * sizeof *argv); + if (!argv) scr_trap("out of memory"); + argv[0] = scr_dyn_new_num(method); + for (size_t i = 0; i < argc; i++) argv[i + 1] = args[i]; + scr_dyn_this_push_dyn(self); + ScrDyn *result = scr_dyn_call(bc_compiled_dispatch, argv, argc + 1, bc_names[BC_EMITTER]); + scr_dyn_this_pop(); + scr_dyn_release(argv[0]); + free(argv); + return result; +} + +/* EventEmitter.prototype's method on a receiver with stream state, or on + * a compiled emitter. */ static bool bc_stream_event(int method, ScrDyn *self, ScrDyn *const *args, size_t argc, ScrDyn **result) { - return bc_stream_ops != NULL && bc_stream_ops->event(method, self, args, argc, result); + if (bc_stream_ops != NULL && bc_stream_ops->event(method, self, args, argc, result)) return true; + if (bc_compiled_dispatch == NULL || self->kind != SCR_DYN_TYPED_REF) return false; + *result = bc_compiled_event(method, self, args, argc); + return true; } /* ── property helpers ───────────────────────────────────────────────── */ @@ -1436,6 +1462,8 @@ static void bc_teardown(void) { if (bc_symbols[i]) scr_dyn_release(bc_symbols[i]); bc_symbols[i] = NULL; } + if (bc_compiled_dispatch) scr_dyn_release(bc_compiled_dispatch); + bc_compiled_dispatch = NULL; if (scr_exc_pending()) scr_exc_clear(); } @@ -1542,6 +1570,19 @@ ScrDyn *scr_dyn_builtin_class(double kind) { return scr_dyn_retain(bc_class(index)); } +/* EventEmitter.prototype[name], +1, read off a compiled emitter or the + * class; the first read installs the program's dispatch for compiled + * emitters (borrowed, retained). */ +ScrDyn *scr_dyn_emitter_method(const ScrStr *name, ScrDyn *dispatch) { + ScrDyn *ctor = bc_class(BC_EMITTER); + if (!bc_compiled_dispatch) bc_compiled_dispatch = scr_dyn_retain(dispatch); + ScrDyn *prototype = bc_get(ctor, "prototype"); + if (!prototype) return NULL; + ScrDyn *method = bc_get(prototype, name->data); + scr_dyn_release(prototype); + return method; +} + /* OrdinaryHasInstance over the value's prototype chain. */ static bool bc_chain_has(ScrDyn *value, ScrDyn *prototype) { if (value->kind != SCR_DYN_OBJ && value->kind != SCR_DYN_ARR && value->kind != SCR_DYN_FUNC && diff --git a/tests/corpus/6600-emitter-method-values-untyped.ts b/tests/corpus/6600-emitter-method-values-untyped.ts new file mode 100644 index 000000000..1675c97d6 --- /dev/null +++ b/tests/corpus/6600-emitter-method-values-untyped.ts @@ -0,0 +1,75 @@ +// EventEmitter.prototype's methods read as values through untyped +// references to compiled emitters: a program class extending EventEmitter +// and `new EventEmitter()` seen through any and unknown. +import { EventEmitter } from "node:events"; + +class Bus extends EventEmitter { + label = "bus"; + ping(): string { + return `${this.label} ping`; + } +} + +const bus = new Bus(); +const o: any = bus; +console.log(typeof o.on, typeof o.emit, typeof o.listeners, typeof o.ping); +console.log(o.on === o.addListener, o.off === o.removeListener, o.on === o.once); +console.log(o.on === EventEmitter.prototype.on, o.emit === EventEmitter.prototype.emit); +console.log(o.on.name, o.off.name, o.emit.name, o.once.name); +console.log(o.on.length, o.emit.length, o.listeners.length, o.eventNames.length); + +// call, apply and bind with the emitter as the receiver +const add = o.on; +console.log(add.call(o, "a", (x: unknown, y: unknown) => console.log("a:", x, y)) === o); +console.log(o.emit.apply(o, ["a", 1, 2])); +const emit = o.emit.bind(o); +console.log(emit("a", "x"), emit("missing")); +console.log(o.listenerCount.call(o, "a"), o.eventNames.call(o)); +const once = o.once; +once.call(o, "b", () => console.log("b once")); +console.log(emit("b"), emit("b")); +const prepend = o.prependListener; +prepend.call(o, "a", () => console.log("a first")); +emit("a", 3, 4); +console.log(Reflect.apply(o.listenerCount, o, ["a"])); + +// the argument count matters to removeAllListeners and emit +o.on("c", () => {}); +o.on("d", () => {}); +console.log(o.removeAllListeners.call(o, "c") === o, o.eventNames()); +o.removeAllListeners.call(o); +console.log(o.eventNames()); +o.on("many", (...args: unknown[]) => console.log("many:", args.length, args.join(","))); +console.log(emit("many", 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)); +console.log(o.setMaxListeners.call(o, 3) === o, o.getMaxListeners.call(o)); + +// off through a detached method value +const listener = () => console.log("never"); +o.on("e", listener); +const off = o.off; +off.call(o, "e", listener); +console.log(o.emit("e"), o.listenerCount("e")); + +// membership: inherited, not own +console.log("on" in o, "emit" in o, Object.prototype.hasOwnProperty.call(o, "on")); +const key = ["emit", "listeners"][Number(o.listenerCount("a") > 5)]!; +console.log(typeof o[key], o[key] === EventEmitter.prototype.emit); + +// the class's own members still answer +console.log(o.ping(), o.ping.call(o)); + +// new EventEmitter() through unknown +const plain = new EventEmitter(); +const seen: unknown = plain; +const p = seen as any; +const pOn = p.on; +pOn.call(p, "z", (v: unknown) => console.log("z:", v)); +console.log(p.emit.call(p, "z", 9), p.listeners.call(p, "z").length); +console.log(p.on === o.on, p.rawListeners === EventEmitter.prototype.rawListeners); + +// a detached method called without a receiver throws a TypeError +try { + add("x", () => {}); +} catch (err) { + console.log("detached:", err instanceof TypeError); +} diff --git a/tests/corpus/6601-emitter-method-values.cjs b/tests/corpus/6601-emitter-method-values.cjs new file mode 100644 index 000000000..7873b5f8e --- /dev/null +++ b/tests/corpus/6601-emitter-method-values.cjs @@ -0,0 +1,73 @@ +"use strict"; +// EventEmitter.prototype's methods as values in JavaScript: read off a +// compiled subclass, off `new EventEmitter()`, off EventEmitter.prototype +// by name, and shared with constructor-function emitters. +const EventEmitter = require("events"); +const util = require("util"); + +class Queue extends EventEmitter { + constructor() { + super(); + this.items = []; + } + push(v) { + this.items.push(v); + this.emit("push", v); + return this; + } +} + +const q = new Queue(); +const on = q.on; +on.call(q, "push", (v) => console.log("pushed", v)); +q.push(1).push(2); +for (const m of ["on", "addListener", "emit", "off", "once", "prependOnceListener", "listeners", "listenerCount", "eventNames"]) { + console.log(m, typeof q[m], q[m] === EventEmitter.prototype[m], q[m].name, q[m].length); +} + +// EventEmitter.prototype's functions called on compiled emitters +const proto = EventEmitter.prototype; +proto.on.call(q, "drain", () => console.log("drained")); +console.log(proto.emit.call(q, "drain"), proto.listenerCount.call(q, "push")); +console.log(proto.eventNames.call(q)); +proto.removeAllListeners.call(q, "drain"); +console.log(q.eventNames(), Reflect.apply(proto.listenerCount, q, ["drain"])); + +// new EventEmitter() +const plain = new EventEmitter(); +const emit = plain.emit; +plain.on("hi", (a, b, c) => console.log("hi", a, b, c)); +console.log(emit.call(plain, "hi", 1, "two", [3])); +console.log(emit.call(plain, "hi", 1, 2, 3, 4, 5, 6, 7, 8, 9)); +const fn = () => console.log("never"); +plain.once("x", fn); +const off = plain.off; +off.call(plain, "x", fn); +console.log(plain.emit("x"), plain.listenerCount("x")); +const bound = plain.prependListener.bind(plain); +bound("hi", () => console.log("hi first")); +plain.emit("hi", "a"); + +// a constructor-function emitter shares the same functions +function Legacy() { + EventEmitter.call(this); +} +util.inherits(Legacy, EventEmitter); +const legacy = new Legacy(); +console.log(legacy.on === q.on, legacy.emit === plain.emit); +legacy.on("tick", (n) => console.log("legacy tick", n)); +q.on.call(legacy, "tick", (n) => console.log("legacy tick again", n)); +console.log(plain.emit.call(legacy, "tick", 7), legacy.listenerCount("tick")); + +// method values of a class expression whose instances escape +function makeEmitter() { + return new (class extends EventEmitter { + fire() { + return this.emit("fire", "local"); + } + })(); +} +const local = makeEmitter(); +const localOn = local.on; +localOn.call(local, "fire", (v) => console.log("fire", v)); +console.log(local.fire(), "on" in local, Object.keys(local).includes("on")); diff --git a/tests/corpus/6602-emitter-method-values-typed.ts b/tests/corpus/6602-emitter-method-values-typed.ts new file mode 100644 index 000000000..45fb7f1fc --- /dev/null +++ b/tests/corpus/6602-emitter-method-values-typed.ts @@ -0,0 +1,34 @@ +// EventEmitter.prototype's methods read as values from typed references +// and from EventEmitter.prototype in TypeScript. +import { EventEmitter } from "node:events"; +import * as events from "node:events"; + +class Counter extends EventEmitter { + count = 0; + bump(): void { + this.count++; + this.emit("bump", this.count); + } +} + +const counter = new Counter(); +const plain = new EventEmitter(); +console.log(typeof counter.on, typeof plain.emit, typeof EventEmitter.prototype.listeners); +console.log(counter.on === plain.on, counter.addListener === EventEmitter.prototype.on); +console.log(events.EventEmitter.prototype.off === plain.removeListener); + +const onValue: unknown = counter.on; +const emitValue: unknown = plain.emit; +console.log(typeof onValue, onValue === EventEmitter.prototype.addListener, emitValue === counter.emit); + +const register = counter.on as any; +register.call(counter, "bump", (n: number) => console.log("bump", n)); +counter.bump(); +EventEmitter.prototype.on.call(counter, "bump", (n: number) => console.log("bump again", n)); +counter.bump(); +console.log(EventEmitter.prototype.listenerCount.call(counter, "bump")); +console.log(EventEmitter.prototype.emit.call(plain, "nothing")); + +const pick = (emitter: EventEmitter, name: "on" | "emit"): unknown => + name === "on" ? emitter.on : emitter.emit; +console.log(pick(counter, "on") === pick(plain, "on"), pick(counter, "emit") === pick(plain, "on"));