From a0e850a9f15b6e750509c788ee84b03d8c770c95 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 13:41:54 -0700 Subject: [PATCH 1/5] Share one listener list between typed and untyped uses of an EventEmitter An emitter used both through its static type and through an untyped reference aborted: a typed `on` followed by an untyped `emit` (or a typed `emit` from a class body reaching a listener added through `any`) hit "computed event collided with a fixed event", and an untyped `emit('error', ...)` trapped outright. Listener entries held either a typed adapter or a checked-dynamic thunk, and the emit core trapped when an entry lacked the form the emit carried. Entries may now carry both forms. Listeners registered through scr_emitter_on_dyn keep their thunk beside the typed adapter; typed emits call the adapter and untyped emits call the thunk with the arguments as given. The class dispatch of compiled emitters registers untyped listeners under an event typed code fixes with a typed adapter for its tuple, and converts the arguments of an untyped emit of such an event to the tuple for listeners with typed parameters (scr_emitter_emit_mixed; a mismatch throws the checked conversion's TypeError). Registrations and emits through untyped values otherwise go through scr_emitter_on_any/emit_any: runtime adapters under 'error' and the meta events, and Node's unhandled-'error' contract. The emit core throws instead of trapping when an entry has no usable form. The dynamic half of the registry sits behind a hook only on_any installs, and on_flex/emit_flex keep their plain behavior, so programs without untyped emitter calls link none of it. listenerCount, eventNames, removeAllListeners, listeners, rawListeners, setMaxListeners and getMaxListeners through an untyped value on a compiled emitter answered "is not a function"; they now dispatch to the native registry. Co-Authored-By: Claude Opus 5.5 --- .../limitations/builtin-apis/eventemitter.mdx | 5 + .../compiler/src/backend/llvm/lib-process.ts | 53 ++- .../compiler/src/backend/llvm/lib-shared.ts | 3 + .../lowering/class-dynamic-dispatch.ts | 134 +++++- .../src/frontend/lowering/lower-calls.ts | 6 + .../frontend/lowering/lower-event-emitter.ts | 237 +++++++++- packages/compiler/src/ir/builtin-effects.ts | 4 + .../compiler/src/ir/builtin-signatures.ts | 15 + packages/compiler/src/ir/validate.ts | 20 +- ...80-emitter-typed-untyped-listeners.ts.json | 6 + ...581-emitter-untyped-error-and-meta.ts.json | 6 + ...-emitter-class-expression-escapes.cjs.json | 6 + .../6583-emitter-untyped-surface.ts.json | 6 + ...itter-class-body-untyped-listeners.ts.json | 6 + packages/runtime/src/scr_events_emitter.c | 405 ++++++++++++++++-- packages/runtime/src/scr_events_emitter.h | 28 +- .../6580-emitter-typed-untyped-listeners.ts | 50 +++ .../6581-emitter-untyped-error-and-meta.ts | 59 +++ .../6582-emitter-class-expression-escapes.cjs | 61 +++ tests/corpus/6583-emitter-untyped-surface.ts | 47 ++ ...84-emitter-class-body-untyped-listeners.ts | 54 +++ 21 files changed, 1147 insertions(+), 64 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6580-emitter-typed-untyped-listeners.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6581-emitter-untyped-error-and-meta.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6582-emitter-class-expression-escapes.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6583-emitter-untyped-surface.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6584-emitter-class-body-untyped-listeners.ts.json create mode 100644 tests/corpus/6580-emitter-typed-untyped-listeners.ts create mode 100644 tests/corpus/6581-emitter-untyped-error-and-meta.ts create mode 100644 tests/corpus/6582-emitter-class-expression-escapes.cjs create mode 100644 tests/corpus/6583-emitter-untyped-surface.ts create mode 100644 tests/corpus/6584-emitter-class-body-untyped-listeners.ts diff --git a/docs/content/limitations/builtin-apis/eventemitter.mdx b/docs/content/limitations/builtin-apis/eventemitter.mdx index a9f03b8575..0e085c56f9 100644 --- a/docs/content/limitations/builtin-apis/eventemitter.mdx +++ b/docs/content/limitations/builtin-apis/eventemitter.mdx @@ -1,5 +1,10 @@ - **EventEmitter:** Literal event names use fixed argument tuples. Computed string names require a constant prefix or suffix that excludes `error`, meta events, and stream-internal events; their arguments must match the exact arity and support checked conversion. Unrestricted names, symbols, names that may reach internal events, and typed `listeners()`/`rawListeners()` results for names with possible computed registrations are unsupported. + An emitter reached through an untyped value shares one listener list with its typed uses, so untyped registrations and emits reach typed listeners and typed emits reach untyped listeners. + 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`. `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-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 097fcb4dea..4cee7c1dde 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -852,7 +852,7 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr host.emitPendingCheck(); return { name: t, type: e.type }; } - if (e.fn === "emitter.emitFlex") { + if (e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny") { const args = e.args.map((a) => host.emitExpr(a)); const n = args.length - 2; let payload = "null"; @@ -866,10 +866,57 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr } payload = arr; } - host.declare(`declare zeroext i1 @scr_emitter_emit_flex(ptr, ptr, ptr, ${host.sizeType})`); + const sym = e.fn === "emitter.emitAny" ? "scr_emitter_emit_any" : "scr_emitter_emit_flex"; + host.declare(`declare zeroext i1 @${sym}(ptr, ptr, ptr, ${host.sizeType})`); const t = B.tmp(); B.line( - `${t} = call i1 @scr_emitter_emit_flex(ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${payload}, ${host.sizeType} ${n})`, + `${t} = call i1 @${sym}(ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${payload}, ${host.sizeType} ${n})`, + ); + host.emitPendingCheck(); + return { name: t, type: e.type }; + } + if (e.fn === "emitter.emitMixed") { + // (recv, name, k, ...k dyn arguments, ...converted tuple): the dyn + // arguments ride an exact-length array like emitFlex, the tuple the + // pointer-classed variadic tail like emitter.emit. Borrowed; may throw. + const count = e.args[2]!; + const k = count.kind === "numLit" ? count.value : 0; + const args = [e.args[0]!, e.args[1]!, ...e.args.slice(3)].map((a) => host.emitExpr(a)); + let payload = "null"; + if (k > 0) { + const arr = B.slot(); + B.entryAllocas.push(`${arr} = alloca [${k} x ptr]`); + for (let i = 0; i < k; i++) { + const slot = B.tmp(); + B.line(`${slot} = getelementptr inbounds [${k} x ptr], ptr ${arr}, i64 0, i64 ${i}`); + B.line(`store ptr ${args[i + 2]!.name}, ptr ${slot}`); + } + payload = arr; + } + const tuple = args.slice(2 + k).map((a) => { + const ty = host.llType(a.type); + if (ty === "double" || ty === "i1") { + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty} ; EventEmitter scalar argument`); + B.line(`store ${ty} ${a.name}, ptr ${slot}`); + return `ptr ${slot}`; + } + return `ptr ${a.name}`; + }); + host.declare( + `declare zeroext i1 @scr_emitter_emit_mixed(ptr, ptr, ptr, ${host.sizeType}, ...)`, + ); + const t = B.tmp(); + B.line( + `${t} = call zeroext i1 (ptr, ptr, ptr, ${host.sizeType}, ...) @scr_emitter_emit_mixed(` + + [ + `ptr ${args[0]!.name}`, + `ptr ${args[1]!.name}`, + `ptr ${payload}`, + `${host.sizeType} ${k}`, + ...tuple, + ].join(", ") + + `)`, ); host.emitPendingCheck(); return { name: t, type: e.type }; diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 8cd40240f2..007c78c9e1 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1050,6 +1050,7 @@ export const LIB_FN_SYMS: Record = { "emitter.off": "scr_emitter_off", "emitter.offDyn": "scr_emitter_off_dyn", "emitter.onFlex": "scr_emitter_on_flex", + "emitter.onAny": "scr_emitter_on_any", "emitter.checkListener": "scr_emitter_check_listener", "emitter.removeAll": "scr_emitter_remove_all", "emitter.emitError": "scr_emitter_emit_error", @@ -1058,6 +1059,8 @@ export const LIB_FN_SYMS: Record = { "emitter.countDyn": "scr_emitter_listener_count_dyn", "emitter.names": "scr_emitter_event_names", "emitter.listeners": "scr_emitter_listeners", + "emitter.listenersDyn": "scr_emitter_listeners_dyn", + "emitter.hasTyped": "scr_emitter_has_typed", "emitter.setMax": "scr_emitter_set_max", "emitter.setMaxChk": "scr_emitter_set_max_chk", "emitter.setDefaultMaxChk": "scr_emitter_set_default_max_chk", diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index de84e3fed7..c9e6768a6e 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -68,7 +68,11 @@ import { isClassCallback } from "./class-callbacks.js"; import { CaughtValueDispatch } from "./caught-value-dispatch.js"; import { refreshDescriptorGuards } from "./class-descriptors.js"; import { ClassConstructionDispatch } from "./class-construction.js"; -import { emitterRooted } from "./lower-event-emitter.js"; +import { + emitterRooted, + lowerDynamicEmitterEmit, + lowerDynamicEmitterOn, +} from "./lower-event-emitter.js"; import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js"; import { STREAM_OPERATORS } from "./lower-stream.js"; import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js"; @@ -97,6 +101,24 @@ const EMITTER_METHODS = new Set([ "off", "removeListener", ]); +/** The rest of EventEmitter.prototype, answered through an untyped value + * on emitters that are not streams (streams keep their own surface). */ +const EMITTER_INTROSPECTION_METHODS = new Set([ + "listenerCount", + "eventNames", + "removeAllListeners", + "listeners", + "rawListeners", + "setMaxListeners", + "getMaxListeners", +]); +/** True when the class is or extends one of the runtime stream classes. */ +function streamRooted(info: ClassInfo): boolean { + for (let c: ClassInfo | null = info; c; c = c.base) { + if (c.builtinStream !== undefined) return true; + } + return false; +} const STREAM_BOOL_PROPERTIES = new Set([ "readable", "readableEnded", @@ -1223,6 +1245,13 @@ export class ClassDynamicDispatch { const matching = this.propertyReceivers(lowerer, true).flatMap(({ info }) => { if (!info || info.fields.has(method) || info.builtinError) return []; if (emitterRooted(lowerer, info) && EMITTER_METHODS.has(method)) return [info]; + if ( + emitterRooted(lowerer, info) && + !streamRooted(info) && + EMITTER_INTROSPECTION_METHODS.has(method) && + !findMethodOn(lowerer, info, method) + ) + return [info]; if (info.builtinStream) { return STREAM_METHODS.has(method) || (STREAM_OPERATORS.has(method) && info.builtinStream !== "w") @@ -5055,6 +5084,66 @@ export class ClassDynamicDispatch { return [store, { kind: "return", value: null, loc }]; } + /** EventEmitter.prototype's introspection and bookkeeping members on an + * emitter seen through an untyped value: Node's results over the native + * listener registry. */ + private emitterIntrospection(lowerer: Lowerer, dispatch: Dispatch, receiver: IrExpr): IrStmt[] { + const { method, loc } = dispatch.source; + const incoming = dispatch.source.args.map((_, i) => varRef(`p.${i + 1}`, DYN, loc)); + const ret = (value: IrExpr): IrStmt[] => [ + { kind: "return", value: lowerer.coerceToExpected(value, DYN), loc }, + ]; + const call = (fn: IrLibFn, args: IrExpr[], type: IrType): IrExpr => ({ + kind: "libCall", + fn, + args: [receiver, ...args], + type, + loc, + }); + // Node indexes _events with the argument itself: an omitted name is + // the key "undefined". + const eventName = (): IrExpr => + incoming.length + ? lowerer.coerceToExpected(incoming[0]!, STRING) + : { kind: "strLit", value: "undefined", type: STRING, loc }; + switch (method) { + case "listenerCount": + return ret( + incoming.length >= 2 + ? call("emitter.countDyn", [eventName(), incoming[1]!], F64) + : call("emitter.count", [eventName()], F64), + ); + case "eventNames": + return ret(call("emitter.names", [], { kind: "array", elem: STRING })); + case "removeAllListeners": + return ret( + incoming.length + ? call( + "emitter.removeAll", + [eventName(), { kind: "boolLit", value: false, type: BOOL, loc }], + receiver.type, + ) + : call( + "emitter.removeAll", + [ + { kind: "strLit", value: "", type: STRING, loc }, + { kind: "boolLit", value: true, type: BOOL, loc }, + ], + receiver.type, + ), + ); + case "listeners": + case "rawListeners": + return ret(call("emitter.listenersDyn", [eventName()], DYN)); + case "setMaxListeners": + return ret( + call("emitter.setMaxChk", [incoming[0] ?? dynUndefinedExpr(loc)], receiver.type), + ); + default: + return ret(call("emitter.getMax", [], F64)); + } + } + private methodBody( lowerer: Lowerer, dispatch: Dispatch, @@ -5070,6 +5159,14 @@ export class ClassDynamicDispatch { loc, }, ]; + if ( + emitterRooted(lowerer, info) && + !streamRooted(info) && + EMITTER_INTROSPECTION_METHODS.has(method) && + !findMethodOn(lowerer, info, method) + ) { + return this.emitterIntrospection(lowerer, dispatch, receiver); + } if ( info.builtinStream || (emitterRooted(lowerer, info) && @@ -5090,18 +5187,37 @@ export class ClassDynamicDispatch { ]; if (method === "emit") { if (!incoming.length) return fence(); - return ret({ - kind: "libCall", - fn: "emitter.emitFlex", - args: [receiver, lowerer.coerceToExpected(incoming[0]!, STRING), ...incoming.slice(1)], - type: BOOL, - loc, - }); + const event = lowerer.coerceToExpected(incoming[0]!, STRING); + const bridged = streamRooted(info) + ? null + : lowerDynamicEmitterEmit(lowerer, receiver, event, incoming.slice(1), loc); + return ret( + bridged ?? { + kind: "libCall", + fn: "emitter.emitAny", + args: [receiver, event, ...incoming.slice(1)], + type: BOOL, + loc, + }, + ); } if (registering || removing) { if (incoming.length < 2) return fence(); const event = lowerer.coerceToExpected(incoming[0]!, STRING); const cb = incoming[1]!; + const bridged = + registering && !streamRooted(info) + ? lowerDynamicEmitterOn( + lowerer, + receiver, + event, + cb, + method === "once" || method === "prependOnceListener", + method.startsWith("prepend"), + loc, + ) + : null; + if (bridged) return ret(bridged); return [ { kind: "exprStmt", @@ -5113,7 +5229,7 @@ export class ClassDynamicDispatch { fn: registering ? info.builtinStream ? "stream.onDyn" - : "emitter.onFlex" + : "emitter.onAny" : "emitter.offDyn", args: [ receiver, diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index b1b4086cd7..498e2359d7 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4019,6 +4019,12 @@ const DYN_DISPATCH_METHODS = new Set([ "prependOnceListener", "listeners", "listenerCount", + // The rest of EventEmitter.prototype, which compiled emitters answer + // through the class dispatch (class-dynamic-dispatch.ts). + "rawListeners", + "eventNames", + "setMaxListeners", + "getMaxListeners", "write", "end", "destroy", diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index f8588fe48e..6b79666b1b 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -53,6 +53,8 @@ import { BOOL, canBoxFuncIntoDyn, canConvertToDyn, + canDynCheckTo, + isDynTypedRefType, DYN, F64, type IrExpr, @@ -69,7 +71,7 @@ import { VOID, } from "../../ir/ir.js"; import { STREAM_FORCED_EVENT_NAMES, streamForcedTuple, streamSidesOf } from "./lower-stream.js"; -import { boolLit, strLit, varRef } from "../../ir/build.js"; +import { boolLit, dynUndefinedExpr, strLit, varRef } from "../../ir/build.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerAddAbortListener } from "./builtin-calls.js"; import { requestGeneralStreamSurface } from "./general-streams.js"; @@ -931,6 +933,239 @@ function lowerDynListenerCall( }; } +/** The literal events typed code dispatches with a fixed tuple — it emits + * them or listens with typed parameters — whose names an untyped receiver + * may also use. 'error' and the meta events are excluded: the runtime fixes + * their tuples itself (scr_emitter_on_flex / scr_emitter_emit_flex). */ +function bridgedEvents(lowerer: Lowerer): { name: string; tuple: IrType[] }[] { + const table = emitterEvents(lowerer); + const events: { name: string; tuple: IrType[] }[] = []; + for (const [key, sig] of table) { + if (key === "error" || key === ERROR_MONITOR_EVENT || META_EVENTS.has(key)) continue; + if (sig.conflict !== null || (!sig.fromEmit && sig.typedListener !== true)) continue; + if (flexibleJsEvent(key, sig)) continue; + if (computedPatterns(lowerer).some((pattern) => patternMatchesName(pattern, key))) continue; + events.push({ name: eventKeyName(key), tuple: sig.tuple }); + } + return events; +} + +/** `emit(name, ...args)` through an untyped value on a (non-stream) + * emitter-rooted receiver, when typed code fixes the tuple of some event. + * Listeners registered as checked-dynamic functions receive the arguments + * as given; when the name is such an event and a listener with typed + * parameters is registered, the arguments also convert to the event's tuple + * (a mismatch throws the checked conversion's TypeError). Null when no event + * needs this, so the caller keeps the plain untyped emit. */ +export function lowerDynamicEmitterEmit( + lowerer: Lowerer, + receiver: IrExpr, + name: IrExpr, + payload: IrExpr[], + loc: SrcLoc, +): IrExpr | null { + const recvT = receiver.type; + if (recvT.kind !== "object") return null; + const k = payload.length; + const getRecord = (id: string) => lowerer.shapes.get(id); + const getUnion = (id: string) => lowerer.unions.get(id); + const checkable = (t: IrType): boolean => + t.kind === "dyn" || + isDynTypedRefType(t) || + isUnitType(t) || + (t.kind === "union" + ? (getUnion(t.unionId)?.arms.every(checkable) ?? false) + : canDynCheckTo(t, getRecord, getUnion)); + const converted = (tuple: IrType[]): IrExpr[] | null => { + if (!tuple.every(checkable)) return null; + const out: IrExpr[] = []; + for (let i = 0; i < tuple.length; i++) { + const arg = i < k ? varRef(`a.${i}`, DYN, loc) : dynUndefinedExpr(loc); + const value = lowerer.coerceToExpected(arg, tuple[i]!); + if (!typeEquals(value.type, tuple[i]!)) return null; + out.push(value); + } + return out; + }; + const events = bridgedEvents(lowerer) + .map((event) => ({ ...event, args: converted(event.tuple) })) + .filter((event) => event.args !== null); + if (events.length === 0) return null; + const key = `emitter.dynEmit:${typeKey(recvT)}:${k}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = `%emitter.dynEmit.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, helper); + const params: IrParam[] = [ + { localId: "r.0", name: "r", type: recvT }, + { localId: "n.0", name: "n", type: STRING }, + ...payload.map((_, i) => ({ localId: `a.${i}`, name: `a${i}`, type: DYN })), + ]; + const r = varRef("r.0", recvT, loc); + const n = varRef("n.0", STRING, loc); + const dynArgs = payload.map((_, i) => varRef(`a.${i}`, DYN, loc)); + const body: IrStmt[] = events.map((event): IrStmt => ({ + kind: "if", + cond: { + kind: "strEq", + negated: false, + left: n, + right: strLit(event.name, loc), + type: BOOL, + loc, + }, + then: [ + { + kind: "if", + cond: { kind: "libCall", fn: "emitter.hasTyped", args: [r, n], type: BOOL, loc }, + then: [ + { + kind: "return", + value: { + kind: "libCall", + fn: "emitter.emitMixed", + args: [ + r, + n, + { kind: "numLit", value: k, type: F64, loc }, + ...dynArgs, + ...event.args!, + ], + type: BOOL, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }, + ], + else_: null, + loc, + })); + body.push({ + kind: "return", + value: { kind: "libCall", fn: "emitter.emitAny", args: [r, n, ...dynArgs], type: BOOL, loc }, + loc, + }); + lowerer.liftedFns.push({ + name: helper, + params, + returnType: BOOL, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), + body, + loc, + }); + } + return { kind: "call", callee: helper, args: [receiver, name, ...payload], type: BOOL, loc }; +} + +/** on/once/prepend*(name, fn) through an untyped value on a (non-stream) + * emitter-rooted receiver, when typed code fixes the tuple of some event. + * Under such an event's name the function registers with a typed adapter + * for typed emits as well as its checked-dynamic thunk for untyped ones; + * other names register the thunk alone. Null when no event needs this. */ +export function lowerDynamicEmitterOn( + lowerer: Lowerer, + receiver: IrExpr, + name: IrExpr, + cb: IrExpr, + once: boolean, + prepend: boolean, + loc: SrcLoc, +): IrExpr | null { + const recvT = receiver.type; + if (recvT.kind !== "object") return null; + const getRecord = (id: string) => lowerer.shapes.get(id); + const getUnion = (id: string) => lowerer.unions.get(id); + const events = bridgedEvents(lowerer).filter((event) => + event.tuple.every((t) => t.kind === "dyn" || canConvertToDyn(t, getRecord, getUnion)), + ); + if (events.length === 0) return null; + const key = `emitter.dynOn:${typeKey(recvT)}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = `%emitter.dynOn.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, helper); + const params: IrParam[] = [ + { localId: "r.0", name: "r", type: recvT }, + { localId: "n.0", name: "n", type: STRING }, + { localId: "cb.0", name: "cb", type: DYN }, + { localId: "o.0", name: "o", type: BOOL }, + { localId: "p.0", name: "p", type: BOOL }, + ]; + const locals: IrLocal[] = params.map((p) => ({ + id: p.localId, + name: p.name, + type: p.type, + mutable: false, + })); + const r = varRef("r.0", recvT, loc); + const n = varRef("n.0", STRING, loc); + const fn = varRef("cb.0", DYN, loc); + const o = varRef("o.0", BOOL, loc); + const p = varRef("p.0", BOOL, loc); + const body: IrStmt[] = [ + { + kind: "exprStmt", + expr: { kind: "libCall", fn: "emitter.checkListener", args: [fn], type: VOID, loc }, + loc, + }, + ]; + events.forEach((event, i) => { + const adapterT: IrType = { kind: "func", params: event.tuple, ret: VOID }; + const adapter = `a.${i}`; + locals.push({ id: adapter, name: "a", type: adapterT, mutable: false }); + body.push({ + kind: "if", + cond: { + kind: "strEq", + negated: false, + left: n, + right: strLit(event.name, loc), + type: BOOL, + loc, + }, + then: [ + { + kind: "varDecl", + localId: adapter, + init: { kind: "dynCheck", value: fn, type: adapterT, loc }, + loc, + }, + { + kind: "return", + value: { + kind: "libCall", + fn: "emitter.onDyn", + args: [r, n, fn, varRef(adapter, adapterT, loc), o, p], + type: recvT, + loc, + }, + loc, + }, + ], + else_: null, + loc, + }); + }); + body.push({ + kind: "return", + value: { kind: "libCall", fn: "emitter.onAny", args: [r, n, fn, o, p], type: recvT, loc }, + loc, + }); + lowerer.liftedFns.push({ name: helper, params, returnType: recvT, locals, body, loc }); + } + return { + kind: "call", + callee: helper, + args: [receiver, name, cb, boolLit(once, loc), boolLit(prepend, loc)], + type: recvT, + loc, + }; +} + /** A listener argument: lowered, checked void-returning, and its * parameters checked as a typeEquals PREFIX of the event tuple. */ function lowerListenerArg( diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index d4e96a35ac..8696e2139d 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -232,12 +232,16 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ // 'error' has no listener, Node's unhandled-'error' contract. "emitter.emit", "emitter.emitFlex", + "emitter.emitAny", + "emitter.emitMixed", + "emitter.listenersDyn", "emitter.emitError", "emitter.on", "emitter.off", "emitter.checkListener", "emitter.onDyn", "emitter.onFlex", + "emitter.onAny", "emitter.offDyn", "emitter.onData", "emitter.onDataDyn", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 1ffae75360..4dc7b69e01 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3716,16 +3716,31 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "emitter.checkListener": { argTypes: [DYN], result: VOID }, "emitter.onDyn": { argTypes: [null, STRING, DYN, null, BOOL, BOOL], result: VOID }, "emitter.onFlex": { argTypes: [null, STRING, DYN, BOOL, BOOL], result: VOID }, + /** on/once/prepend* and emit through an untyped value (the class + * dispatch of compiled emitters): onFlex/emitFlex plus runtime typed + * adapters under 'error' and the meta events, and Node's unhandled-'error' + * contract. emitAny is VARIADIC like emitFlex. */ + "emitter.onAny": { argTypes: [null, STRING, DYN, BOOL, BOOL], result: VOID }, "emitter.offDyn": { argTypes: [null, STRING, DYN], result: VOID }, "emitter.removeAll": { argTypes: [null, STRING, BOOL], result: VOID }, "emitter.emit": { argTypes: [null, STRING], result: BOOL }, "emitter.emitFlex": { argTypes: [null, STRING], result: BOOL }, + "emitter.emitAny": { argTypes: [null, STRING], result: BOOL }, + /** An untyped emit of an event with a fixed tuple: (recv, name, k, then + * k checked-dynamic arguments as given, then the event's converted + * tuple) — VARIADIC like emitter.emit. hasTyped answers whether a + * listener of the name needs the converted tuple. */ + "emitter.emitMixed": { argTypes: [null, STRING, F64], result: BOOL }, + "emitter.hasTyped": { argTypes: [null, STRING], result: BOOL }, "emitter.emitError": { argTypes: [null, STRING, null], result: BOOL }, "emitter.count": { argTypes: [null, STRING], result: F64 }, "emitter.countFn": { argTypes: [null, STRING, null], result: F64 }, "emitter.countDyn": { argTypes: [null, STRING, DYN], result: F64 }, "emitter.names": { argTypes: [null], result: VOID }, "emitter.listeners": { argTypes: [null, STRING], result: VOID }, + /** listeners()/rawListeners() through an untyped value: a checked- + * dynamic array (throws when a listener has typed parameters). */ + "emitter.listenersDyn": { argTypes: [null, STRING], result: DYN }, /** Stream-'data' registration twins of emitter.on/onDyn (same args): * chosen at registration sites whose receiver is stream-rooted, so the * backend emits DATA thunks — the runtime's 'data' emission carries diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 2a2ddbd2f1..3ec68f28ca 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4061,6 +4061,8 @@ function validateFunction( const variadic = e.fn === "emitter.emit" || e.fn === "emitter.emitFlex" || + e.fn === "emitter.emitAny" || + e.fn === "emitter.emitMixed" || e.fn === "readable.new" || e.fn === "writable.new" || e.fn === "duplex.new" || @@ -5828,6 +5830,7 @@ function validateFunction( e.fn === "emitter.off" || e.fn === "emitter.onDyn" || e.fn === "emitter.onFlex" || + e.fn === "emitter.onAny" || e.fn === "stream.onDyn" || e.fn === "emitter.offDyn" || e.fn === "emitter.onData" || @@ -5882,14 +5885,27 @@ function validateFunction( if (!ok) err(`libCall emitter.listeners must return a func array`, e.loc); return true; } - if (e.fn === "emitter.emitFlex") { + if (e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny") { if (e.args.slice(2).some((arg) => arg.type.kind !== "dyn")) { - err("libCall emitter.emitFlex payloads must be checked-dynamic", e.loc); + err(`libCall ${e.fn} payloads must be checked-dynamic`, e.loc); + } + } + if (e.fn === "emitter.emitMixed") { + const count = e.args[2]; + const k = count?.kind === "numLit" ? count.value : -1; + if (!Number.isInteger(k) || k < 0 || e.args.length < 3 + k) { + err("libCall emitter.emitMixed needs a literal argument count", e.loc); + } else if (e.args.slice(3, 3 + k).some((arg) => arg.type.kind !== "dyn")) { + err("libCall emitter.emitMixed untyped arguments must be checked-dynamic", e.loc); } } if ( e.fn === "emitter.emit" || e.fn === "emitter.emitFlex" || + e.fn === "emitter.emitAny" || + e.fn === "emitter.emitMixed" || + e.fn === "emitter.hasTyped" || + e.fn === "emitter.listenersDyn" || e.fn === "emitter.count" || e.fn === "emitter.getMax" || e.fn === "emitter.ctor" || diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6580-emitter-typed-untyped-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6580-emitter-typed-untyped-listeners.ts.json new file mode 100644 index 0000000000..347deebd89 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6580-emitter-typed-untyped-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6580-emitter-typed-untyped-listeners.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6581-emitter-untyped-error-and-meta.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6581-emitter-untyped-error-and-meta.ts.json new file mode 100644 index 0000000000..a8252c6152 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6581-emitter-untyped-error-and-meta.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6581-emitter-untyped-error-and-meta.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6582-emitter-class-expression-escapes.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6582-emitter-class-expression-escapes.cjs.json new file mode 100644 index 0000000000..9cc32b856e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6582-emitter-class-expression-escapes.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6582-emitter-class-expression-escapes.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6583-emitter-untyped-surface.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6583-emitter-untyped-surface.ts.json new file mode 100644 index 0000000000..b570e5d439 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6583-emitter-untyped-surface.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6583-emitter-untyped-surface.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6584-emitter-class-body-untyped-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6584-emitter-class-body-untyped-listeners.ts.json new file mode 100644 index 0000000000..45c0a95ba9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6584-emitter-class-body-untyped-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6584-emitter-class-body-untyped-listeners.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index 4ac7abdf18..c11b7897da 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -27,8 +27,10 @@ * site's writes. Internal (meta-event) emits reuse the same variadic * entry, passing the event NAME as the one argument — meta listeners are * fenced to arity <= 1, so no adapter ever reads the listener argument - * Node would pass second. Computed names disjoint from internal events use a - * checked-dynamic callback and an exact-arity argument vector instead. + * Node would pass second. Listeners registered as checked-dynamic functions + * keep their checked-dynamic thunk, which untyped emits call with the exact + * argument vector; an entry can hold both forms, so typed and untyped + * registrations and emits of one name share one list. * * The special 'error' event routes through scr_emitter_emit_error: with no * 'error' listener the Error payload THROWS through the exception cell @@ -70,11 +72,19 @@ typedef struct ScrEeEntry { * listeners()/rawListeners() answer it — Node's identity semantics. */ ScrClosure *orig; ScrEeInvoke inv; - /* Computed-name registration: the original closure and its checked- - * dynamic thunk, called with the exact argument count. Holding the - * closure directly keeps its capture edges visible to cycle collection. */ + /* Checked-dynamic registration (computed names, and every listener + * registered as a dynamic function value): the original closure and its + * checked-dynamic thunk, called with the exact argument count of an + * untyped emit. Holding the closure directly keeps its capture edges + * visible to cycle collection. An entry may carry both forms: a typed + * emit calls `inv`, an untyped one calls the thunk with the arguments as + * given. The descriptor rebuilds the function value for listeners() and + * the meta events. */ ScrClosure *flex_cb; ScrDynThunk flex_invoke; + const char *flex_sig; + const char *flex_name; + uint32_t flex_arity; bool once; bool fired; /* a once entry that already ran (snapshot skip rule) */ uint32_t refs; @@ -355,6 +365,8 @@ void scr_emitter_release_v(void *em) { scr_emitter_release(em); } /* ── the meta events ('newListener' / 'removeListener') ──────────────── */ static bool scr_ee_emit_va(ScrEmitter *em, ScrStr *name, ...); +static bool scr_ee_emit_mixed_va(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, + int typed, ...); static void scr_ee_symbol_name_fence(const char *what) { char message[160]; @@ -363,10 +375,62 @@ static void scr_ee_symbol_name_fence(const char *what) { scr_throw_error(SCR_ERR_ERROR, scr_str_new(message, strlen(message))); } -/* Fires a meta event with the affected event's NAME as the argument - * (listeners are fenced to arity <= 1, so the listener-function argument - * Node passes second is never read). */ -static void scr_ee_emit_meta(ScrEmitter *em, const char *meta, ScrStr *name) { +/* Whether any entry of the bucket was registered as a checked-dynamic + * function (it can take the untyped argument vector). */ +static bool scr_ee_bucket_has_flex(const ScrEeBucket *b) { + for (size_t i = 0; b && i < b->n; i++) { + if (b->ls[i]->flex_invoke) return true; + } + return false; +} + +/* The checked-dynamic function value of a dynamically registered entry + * (+1), or NULL for a typed listener. Identity lives in the closure, so the + * rebuilt box compares equal to the value that was registered. */ +static ScrDyn *scr_ee_entry_dyn(const ScrEeEntry *e) { + if (!e->flex_invoke) return NULL; + return scr_dyn_new_func(scr_closure_retain(e->flex_cb), e->flex_invoke, e->flex_arity, + e->flex_sig, e->flex_name); +} + +/* A meta event with a listener registered as a checked-dynamic function: + * the untyped argument vector — the affected event's name and, when it was + * registered dynamically, the listener (`fn`, borrowed, may be NULL) — + * beside the typed one-name tuple. False when no such listener exists (the + * caller emits the typed tuple alone). */ +static bool scr_ee_dyn_emit_meta(ScrEmitter *em, ScrStr *meta, ScrStr *name, const ScrDyn *fn) { + if (!scr_ee_bucket_has_flex(scr_ee_bucket_of(em->reg, meta))) return false; + ScrDyn *args[2] = { scr_dyn_new_str(name), (ScrDyn *)fn }; + scr_ee_emit_mixed_va(em, meta, args, fn ? 2 : 1, 1, name); + scr_dyn_release(args[0]); + return true; +} + +/* The function value of a listener about to be removed, for checked- + * dynamic 'removeListener' listeners (+1), or NULL. */ +static ScrDyn *scr_ee_dyn_removed(ScrEmitter *em, const ScrEeEntry *e) { + if (!scr_ee_bucket_has_flex(scr_ee_bucket_find(em->reg, "removeListener", 14))) return NULL; + return scr_ee_entry_dyn(e); +} + +/* The checked-dynamic half of the registry, installed by the first + * registration through an untyped value (scr_emitter_on_any): only such + * listeners take the meta events' untyped argument vector, so programs + * without untyped emitter calls never reach, or link, this half. */ +struct ScrEeDynOps { + bool (*emit_meta)(ScrEmitter *em, ScrStr *meta, ScrStr *name, const ScrDyn *fn); + ScrDyn *(*removed)(ScrEmitter *em, const struct ScrEeEntry *e); +}; +static const ScrEeDynOps scr_ee_dyn_ops = { &scr_ee_dyn_emit_meta, &scr_ee_dyn_removed }; +/* External linkage on purpose: the optimizer must not prove the hook's + * value from this unit's stores and inline the dynamic half back in. */ +SCR_TL const ScrEeDynOps *scr_ee_dyn = NULL; + +/* Fires a meta event with the affected event's NAME as the argument. + * Typed meta listeners are fenced to arity <= 1; listeners registered as + * checked-dynamic functions also receive the listener Node passes second + * when it was itself registered dynamically (`fn`, borrowed, may be NULL). */ +static void scr_ee_emit_meta(ScrEmitter *em, const char *meta, ScrStr *name, const ScrDyn *fn) { if (!scr_ee_has(em->reg, meta)) return; /* Node passes the symbol itself, which the string tuple cannot carry. */ if (scr_ee_is_error_monitor(name)) { @@ -374,7 +438,7 @@ static void scr_ee_emit_meta(ScrEmitter *em, const char *meta, ScrStr *name) { return; } ScrStr *m = scr_str_new(meta, strlen(meta)); - scr_ee_emit_va(em, m, name); + if (!scr_ee_dyn || !scr_ee_dyn->emit_meta(em, m, name, fn)) scr_ee_emit_va(em, m, name); scr_str_release(m); } @@ -435,19 +499,23 @@ static void scr_ee_warn_maybe(ScrEmitter *em, ScrEeBucket *b) { * try). */ static ScrEmitter *scr_ee_add(ScrEmitter *em, ScrStr *name, ScrClosure *cb /*moves*/, ScrClosure *orig /*moves; may be NULL*/, - ScrEeInvoke inv, ScrClosure *flex_cb /*moves; may be NULL*/, - ScrDynThunk flex_invoke, + ScrEeInvoke inv, const ScrDyn *dyn /*borrowed; may be NULL*/, bool once, bool prepend) { scr_ee_reg_ensure(em); - scr_ee_emit_meta(em, "newListener", name); + scr_ee_emit_meta(em, "newListener", name, dyn); ScrEeBucket *b = scr_ee_bucket_ensure(em->reg, name); ScrEeEntry *e = calloc(1, sizeof *e); if (!e) scr_ee_oom(); e->cb = cb; e->orig = orig; e->inv = inv; - e->flex_cb = flex_cb; - e->flex_invoke = flex_invoke; + if (dyn) { + e->flex_cb = scr_closure_retain(dyn->v.fn.clo); + e->flex_invoke = dyn->v.fn.thunk; + e->flex_sig = dyn->v.fn.sig; + e->flex_name = dyn->v.fn.name; + e->flex_arity = dyn->v.fn.arity; + } e->once = once; e->refs = 1; if (b->n == b->cap) { @@ -470,7 +538,7 @@ static ScrEmitter *scr_ee_add(ScrEmitter *em, ScrStr *name, ScrClosure *cb /*mov ScrEmitter *scr_emitter_on(ScrEmitter *em, ScrStr *name, ScrClosure *cb /*moves*/, ScrEeInvoke inv, bool once, bool prepend) { - return scr_ee_add(em, name, cb, NULL, inv, NULL, NULL, once, prepend); + return scr_ee_add(em, name, cb, NULL, inv, NULL, once, prepend); } /* events.once(emitter, name): a pending promise of the event's arguments @@ -553,7 +621,7 @@ ScrPromise *scr_events_once(ScrEmitter *em, ScrStr *name, ScrClosure *boxer, voi * listener it announced is never added (the event's listener stays when * only the 'error' announcement throws). */ ScrCaught *thrown = NULL; - scr_emitter_release(scr_ee_add(em, name, st->event_cb, NULL, inv, NULL, NULL, true, false)); + scr_emitter_release(scr_ee_add(em, name, st->event_cb, NULL, inv, NULL, true, false)); if (scr_exc_pending()) { thrown = scr_exc_take(); scr_ee_unadd(em, name, st->event_cb); @@ -561,7 +629,7 @@ ScrPromise *scr_events_once(ScrEmitter *em, ScrStr *name, ScrClosure *boxer, voi st->event_cb = st->error_cb = NULL; } else if (st->error_cb != NULL) { ScrStr *error = scr_str_new("error", 5); - scr_emitter_release(scr_ee_add(em, error, st->error_cb, NULL, err_inv, NULL, NULL, true, false)); + scr_emitter_release(scr_ee_add(em, error, st->error_cb, NULL, err_inv, NULL, true, false)); if (scr_exc_pending()) { thrown = scr_exc_take(); scr_ee_unadd(em, error, st->error_cb); @@ -613,23 +681,87 @@ void scr_events_once_fire(ScrClosure *cb, ScrArr *args) { /* The dyn-adapted registration (JS-lane checked-dynamic listeners): the * ADAPTER closure (compiler-built — boxes the event tuple to dyn and - * calls the original through the checked-dynamic machinery) is what emit - * invokes; the ORIGINAL function (the dyn box's underlying closure) is - * the entry's identity. cb must be a SCR_DYN_FUNC (the emitted - * scr_emitter_check_listener ran first); adapter MOVES in, cb is - * borrowed. */ + * calls the original through the checked-dynamic machinery) is what a + * typed emit invokes; the ORIGINAL function (the dyn box's underlying + * closure) is the entry's identity, and its checked-dynamic thunk is what + * an untyped emit of the same name invokes, with every argument as given. + * cb must be a SCR_DYN_FUNC (the emitted scr_emitter_check_listener ran + * first); adapter MOVES in, cb is borrowed. */ ScrEmitter *scr_emitter_on_dyn(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, ScrClosure *adapter /*moves*/, ScrEeInvoke inv, bool once, bool prepend) { - return scr_ee_add(em, name, adapter, scr_closure_retain(cb->v.fn.clo), inv, NULL, NULL, once, - prepend); + return scr_ee_add(em, name, adapter, scr_closure_retain(cb->v.fn.clo), inv, + cb->kind == SCR_DYN_FUNC ? cb : NULL, once, prepend); } +/* The typed adapters of a checked-dynamic listener on the events whose + * tuples the runtime itself fixes: 'error' (one Error) and the meta + * events (one name string). caps[0] holds the dynamic function in a traced + * box, so a listener capturing its emitter stays collectable. */ +static void scr_ee_dyn_fire(ScrClosure *self, ScrDyn *arg /*moves*/) { + ScrDyn *fn = scr_box_get_ref(self->caps[0]); /* +1 */ + ScrDyn *args[1] = { arg }; + scr_dyn_release(scr_dyn_call(fn, args, 1, "listener")); + scr_dyn_release(arg); + scr_dyn_release(fn); +} + +static void scr_ee_dyn_error_fire(ScrClosure *self, ScrError *err) { + scr_ee_dyn_fire(self, scr_dyn_from_error(err)); +} + +static void scr_ee_dyn_name_fire(ScrClosure *self, ScrStr *name) { + scr_ee_dyn_fire(self, scr_dyn_new_str(name)); +} + +static ScrClosure *scr_ee_dyn_adapter(const ScrDyn *cb, void *fire) { + ScrClosure *clo = scr_closure_new(fire, 1); + ScrBox *box = scr_box_new_obj(&scr_dyn_retain_v, &scr_dyn_release_v, &scr_dyn_trace_v); + scr_box_set_ref(box, scr_dyn_retain((ScrDyn *)cb)); + clo->caps[0] = box; + return clo; +} + +static bool scr_ee_is_name(const ScrStr *name, const char *literal) { + size_t len = strlen(literal); + return name->len == len && memcmp(name->data, literal, len) == 0; +} + +/* A listener registered as a checked-dynamic function (computed event + * names, and runtime-internal registries such as the worker events, whose + * emits are untyped): its thunk alone. cb must be a function value (the + * caller ran the listener check). */ ScrEmitter *scr_emitter_on_flex(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, bool once, bool prepend) { - if (cb->kind != SCR_DYN_FUNC) scr_trap("scriptc: computed event listener is not a function\n"); - return scr_ee_add(em, name, NULL, NULL, NULL, scr_closure_retain(cb->v.fn.clo), - cb->v.fn.thunk, once, prepend); + if (cb->kind != SCR_DYN_FUNC) { + scr_dyn_check_listener(cb, "listener"); + return scr_emitter_retain(em); + } + return scr_ee_add(em, name, NULL, NULL, NULL, cb, once, prepend); +} + +/* on/once/prepend* through an untyped value, under a name the program never + * fixes a tuple for: the thunk, and under 'error' and the meta events, whose + * tuples typed code and the runtime emit with, a runtime-built typed adapter + * too. Installs the registry's dynamic half. */ +ScrEmitter *scr_emitter_on_any(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, + bool once, bool prepend) { + if (cb->kind != SCR_DYN_FUNC) { + scr_dyn_check_listener(cb, "listener"); + return scr_emitter_retain(em); + } + scr_ee_dyn = &scr_ee_dyn_ops; + void *fire = NULL; + if (!scr_ee_is_error_monitor(name)) { + if (scr_ee_is_name(name, "error")) fire = (void *)&scr_ee_dyn_error_fire; + else if (scr_ee_is_name(name, "newListener") || scr_ee_is_name(name, "removeListener")) + fire = (void *)&scr_ee_dyn_name_fire; + } + if (fire) { + return scr_ee_add(em, name, scr_ee_dyn_adapter(cb, fire), + scr_closure_retain(cb->v.fn.clo), &scr_ee_inv_fixed1, cb, once, prepend); + } + return scr_ee_add(em, name, NULL, NULL, NULL, cb, once, prepend); } /* The adapter-mediated registration (the LLVM backend): adapter is what @@ -638,7 +770,7 @@ ScrEmitter *scr_emitter_on_flex(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, ScrEmitter *scr_emitter_on_via(ScrEmitter *em, ScrStr *name, ScrClosure *orig /*moves*/, ScrClosure *adapter /*moves*/, ScrEeInvoke inv, bool once, bool prepend) { - return scr_ee_add(em, name, adapter, orig, inv, NULL, NULL, once, prepend); + return scr_ee_add(em, name, adapter, orig, inv, NULL, once, prepend); } /* ── fixed-arity invoke shims (scr_runtime.h's contract) ────────────── @@ -702,8 +834,12 @@ static void scr_ee_remove_at_quiet(ScrEmitter *em, ScrEeBucket *b, size_t i) { static void scr_ee_remove_at(ScrEmitter *em, ScrEeBucket *b, size_t i) { ScrStr *name = scr_str_retain(b->name); + /* The removed listener as a function value, for checked-dynamic + * 'removeListener' listeners (Node's second argument). */ + ScrDyn *fn = scr_ee_dyn ? scr_ee_dyn->removed(em, b->ls[i]) : NULL; scr_ee_remove_at_quiet(em, b, i); - scr_ee_emit_meta(em, "removeListener", name); + scr_ee_emit_meta(em, "removeListener", name, fn); + if (fn) scr_dyn_release(fn); scr_str_release(name); } @@ -858,14 +994,25 @@ ScrEmitter *scr_emitter_remove_all(ScrEmitter *em, ScrStr *name, bool all) { /* ── emit ─────────────────────────────────────────────────────────────── */ -/* One dispatch pass over a SNAPSHOT of the bucket: entries invoke in list - * order through their emitted adapters, each with a fresh va_start over - * the emit site's typed tail. A once entry leaves the live list (firing - * 'removeListener', Node's wrapper calls removeListener) BEFORE its - * callback runs, and never runs twice even when a nested emit's snapshot - * still holds it (the shared fired flag — Node's wrapper.fired). A - * listener throw stops the pass (the exception stays pending for the - * caller). Returns whether the bucket had listeners. */ +/* The checked-dynamic argument vector of an untyped emit without + * arguments: flex_args is non-NULL exactly when the emit carries one. */ +static ScrDyn *const scr_ee_no_args[1] = { NULL }; + +/* A typed emit site passes its event's tuple as C variadics (ap_template); + * an untyped emit passes the checked-dynamic arguments as given + * (flex_args); an untyped emit of an event typed code also uses passes both + * (the compiler converted the arguments to the event's tuple for listeners + * with typed parameters). + * + * One dispatch pass over a SNAPSHOT of the bucket: entries invoke in list + * order, each listener registered as a checked-dynamic function through its + * thunk when the emit carries untyped arguments, otherwise through its + * emitted adapter with a fresh va_copy of the typed tail. A once entry + * leaves the live list (firing 'removeListener', Node's wrapper calls + * removeListener) BEFORE its callback runs, and never runs twice even when + * a nested emit's snapshot still holds it (the shared fired flag — Node's + * wrapper.fired). A listener throw stops the pass (the exception stays + * pending for the caller). Returns whether the bucket had listeners. */ static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, ScrDyn *const *flex_args, size_t flex_argc); @@ -877,6 +1024,26 @@ static void scr_ee_emit_error_monitor(ScrEmitter *em, va_list *ap_template, scr_ee_emit_core(em, SCR_EE_ERROR_MONITOR, ap_template, flex_args, flex_argc); } +/* A listener this emit has no argument form for: an untyped emit whose + * arguments have no conversion to a typed listener's parameters, or a typed + * emit reaching a dynamic listener without a typed adapter. Throws (the + * pass stops like a listener throw); never traps. */ +SCR_COLD static void scr_ee_listener_mismatch(const ScrStr *name, bool typed_emit) { + ScrJsonBuf text; + scr_jb_init(&text); + if (typed_emit) { + scr_jb_puts(&text, "emitting '"); + scr_jb_write(&text, name->data, name->len); + scr_jb_puts(&text, "' with typed arguments to a listener registered through an untyped value is not supported yet"); + } else { + scr_jb_puts(&text, "The '"); + scr_jb_write(&text, name->data, name->len); + scr_jb_puts(&text, "' listener has typed parameters the arguments of this untyped emit do not convert to"); + } + ScrStr *message = scr_jb_finish(&text); + scr_throw_error(typed_emit ? SCR_ERR_ERROR : SCR_ERR_TYPE, message); +} + static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, ScrDyn *const *flex_args, size_t flex_argc) { if (name->len == 5 && memcmp(name->data, "error", 5) == 0) { @@ -921,16 +1088,16 @@ static bool scr_ee_emit_core(ScrEmitter *em, ScrStr *name, va_list *ap_template, continue; } } - if (ap_template) { - if (!e->inv) scr_trap("scriptc: computed event collided with a fixed event\n"); + if (flex_args && e->flex_invoke) { + ScrDyn *answer = e->flex_invoke(e->flex_cb, flex_args, flex_argc); + scr_dyn_release(answer); /* EventEmitter ignores callback results. */ + } else if (ap_template && e->inv) { va_list ap; va_copy(ap, *ap_template); e->inv(e->cb, ap); va_end(ap); } else { - if (!e->flex_invoke) scr_trap("scriptc: computed event collided with a fixed event\n"); - ScrDyn *answer = e->flex_invoke(e->flex_cb, flex_args, flex_argc); - scr_dyn_release(answer); /* EventEmitter ignores callback results. */ + scr_ee_listener_mismatch(name, ap_template != NULL); } scr_ee_entry_unref(e); } @@ -946,6 +1113,17 @@ static bool scr_ee_emit_va(ScrEmitter *em, ScrStr *name, ...) { return had; } +/* An untyped emit with the typed tuple too when `typed` (the variadics), + * or the untyped arguments alone. */ +static bool scr_ee_emit_mixed_va(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, + int typed, ...) { + va_list ap; + va_start(ap, typed); + bool had = scr_ee_emit_core(em, name, typed ? &ap : NULL, args ? args : scr_ee_no_args, argc); + va_end(ap); + return had; +} + /* emit(name, ...tuple) — the emitted call site passes the event's unified * tuple as typed C variadics (doubles, pointers, ints for bools); every * argument is BORROWED (adapters retain the +1 each callee owns). May @@ -958,11 +1136,126 @@ bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...) { return had; } +/* An untyped emit of an event typed code also emits or listens to: the + * arguments as given (borrowed) for listeners registered as checked-dynamic + * functions, and the compiler-converted tuple (the variadics, scr_emitter_emit's + * form) for typed listeners. */ +bool scr_emitter_emit_mixed(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, ...) { + va_list ap; + va_start(ap, argc); + bool had = scr_ee_emit_core(em, name, &ap, args ? args : scr_ee_no_args, argc); + va_end(ap); + return had; +} + +/* Whether an untyped emit of `name` reaches a listener without a checked- + * dynamic thunk (one registered with typed parameters), so the arguments + * must convert to the event's tuple first. Includes errorMonitor for + * 'error'. */ +bool scr_emitter_has_typed(ScrEmitter *em, ScrStr *name) { + ScrEeBucket *b = em->reg ? scr_ee_bucket_of(em->reg, name) : NULL; + for (size_t i = 0; b && i < b->n; i++) { + if (!b->ls[i]->flex_invoke) return true; + } + if (!scr_ee_is_error_monitor(name) && scr_ee_is_name(name, "error")) + return scr_emitter_has_typed(em, SCR_EE_ERROR_MONITOR); + return false; +} + +/* Node's ERR_UNHANDLED_ERROR for a non-Error payload: the message quotes + * util.inspect of the payload. This unit renders the primitive payloads + * inspect prints plainly; others report that they are not supported. */ +static void scr_ee_unhandled_error(const ScrDyn *er) { + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "Unhandled error. ("); + bool shown = true; + switch (er->kind) { + case SCR_DYN_UNDEF: scr_jb_puts(&text, "undefined"); break; + case SCR_DYN_NULL: scr_jb_puts(&text, "null"); break; + case SCR_DYN_BOOL: scr_jb_puts(&text, er->v.b ? "true" : "false"); break; + case SCR_DYN_NUM: { + char num[32]; + if (er->v.num == 0 && 1 / er->v.num < 0) { + scr_jb_puts(&text, "-0"); + } else { + size_t len = scr_f64_to_str(er->v.num, num); + scr_jb_write(&text, num, len); + } + break; + } + case SCR_DYN_STR: { + const ScrStr *s = er->v.str; + for (size_t i = 0; i < s->len && shown; i++) { + unsigned char c = (unsigned char)s->data[i]; + if (c < 0x20 || c == 0x7f || c == '\'' || c == '\\') shown = false; + } + if (s->len > 10000) shown = false; + if (shown) { + scr_jb_putc(&text, '\''); + scr_jb_write(&text, s->data, s->len); + scr_jb_putc(&text, '\''); + } + break; + } + default: shown = false; break; + } + ScrStr *message = scr_jb_finish(&text); + if (shown) { + ScrJsonBuf full; + scr_jb_init(&full); + scr_jb_write(&full, message->data, message->len); + scr_jb_putc(&full, ')'); + ScrStr *rendered = scr_jb_finish(&full); + scr_throw_error_msg_code(SCR_ERR_ERROR, rendered->data, rendered->len, "ERR_UNHANDLED_ERROR"); + scr_str_release(rendered); + } else { + const char *refusal = + "emit('error') through an untyped value with this non-Error payload and no 'error' listener is not supported yet"; + scr_throw_error(SCR_ERR_ERROR, scr_str_new(refusal, strlen(refusal))); + } + scr_str_release(message); +} + +/* emit('error', ...) through an untyped value: errorMonitor first; with no + * 'error' listener an Error payload throws itself and anything else throws + * ERR_UNHANDLED_ERROR, Node's contract; otherwise listeners receive the + * arguments as given, and typed listeners the payload as an Error. */ +static bool scr_ee_emit_error_dyn(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { + const ScrDyn *er = argc ? args[0] : scr_dyn_undefined(); + ScrError *err = scr_dyn_err_instanceof(er, SCR_ERR_ERROR) ? scr_errdyn_err_of(er) : NULL; + bool had = false; + if (!scr_ee_has(em->reg, "error")) { + scr_ee_emit_mixed_va(em, SCR_EE_ERROR_MONITOR, args, argc, err != NULL, err); + if (!scr_exc_pending()) { + if (err) scr_dyn_throw(scr_dyn_retain((ScrDyn *)er)); + else scr_ee_unhandled_error(er); + } + } else { + had = scr_ee_emit_mixed_va(em, name, args, argc, err != NULL, err); + } + scr_error_release(err); + return had; +} + +/* An untyped emit of a name typed code never fixes a tuple for (computed + * names, runtime-internal registries): the arguments as given. */ bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { - if (name->len == 5 && memcmp(name->data, "error", 5) == 0) { - scr_trap("scriptc: computed event collided with the error event\n"); + return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc); +} + +/* emit(name, ...args) through an untyped value: the arguments as given. + * 'error' keeps Node's unhandled-error contract, and the meta events reach + * typed listeners with a string name. */ +bool scr_emitter_emit_any(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc) { + if (!scr_ee_is_error_monitor(name)) { + if (scr_ee_is_name(name, "error")) return scr_ee_emit_error_dyn(em, name, args, argc); + if (scr_ee_is_name(name, "newListener") || scr_ee_is_name(name, "removeListener")) { + ScrStr *s = argc && args[0]->kind == SCR_DYN_STR ? args[0]->v.str : NULL; + return scr_ee_emit_mixed_va(em, name, args, argc, s != NULL, s); + } } - return scr_ee_emit_core(em, name, NULL, args, argc); + return scr_ee_emit_core(em, name, NULL, args ? args : scr_ee_no_args, argc); } /* Handle event registries use checked callbacks for every tuple, including @@ -1073,6 +1366,26 @@ ScrArr *scr_emitter_listeners(ScrEmitter *em, ScrStr *name) { return out; } +/* listeners(name) / rawListeners(name) through an untyped value: a +1 + * checked-dynamic array of the listener functions in list order. A + * listener registered with typed parameters has no checked-dynamic + * function value, so such a bucket reports that it is not supported. */ +ScrDyn *scr_emitter_listeners_dyn(ScrEmitter *em, ScrStr *name) { + ScrDyn *out = scr_dyn_new_arr(); + ScrEeBucket *b = em->reg ? scr_ee_bucket_of(em->reg, name) : NULL; + for (size_t i = 0; b && i < b->n; i++) { + ScrDyn *fn = scr_ee_entry_dyn(b->ls[i]); + if (!fn) { + scr_dyn_release(out); + const char *message = "listeners() through an untyped value of an event with a typed listener is not supported yet"; + scr_throw_error(SCR_ERR_ERROR, scr_str_new(message, strlen(message))); + return scr_dyn_new_arr(); + } + scr_dyn_arr_push(out, fn); + } + return out; +} + /* ── max listeners ────────────────────────────────────────────────────── */ /* The checked-dynamic setMaxListeners ladder: non-numbers throw Node's diff --git a/packages/runtime/src/scr_events_emitter.h b/packages/runtime/src/scr_events_emitter.h index 39a7f4b5ba..b66de9e5a4 100644 --- a/packages/runtime/src/scr_events_emitter.h +++ b/packages/runtime/src/scr_events_emitter.h @@ -95,12 +95,27 @@ void scr_emitter_check_listener(const ScrDyn *cb); ScrEmitter *scr_emitter_on_dyn(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, ScrClosure *adapter /*moves*/, ScrEeInvoke inv, bool once, bool prepend); -/* Computed-name listeners retain the original closure and its checked- - * dynamic call thunk, preserving the number of arguments passed to emit. - * The frontend keeps these names disjoint from fixed-tuple event names. */ +/* Listeners registered as checked-dynamic functions retain the original + * closure and its checked-dynamic call thunk, preserving the number of + * arguments passed to an untyped emit. on_dyn entries also carry the typed + * adapter for the event's fixed tuple; on_flex entries are for names the + * program emits only through untyped values or computed names. */ ScrEmitter *scr_emitter_on_flex(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, bool once, bool prepend); +/* Registration and emit through an untyped value (the class dispatch of + * compiled emitters): on_any adds runtime typed adapters under 'error' and + * the meta events and installs the dynamic half; emit_any keeps Node's + * unhandled-'error' contract and gives typed meta listeners a string. */ +ScrEmitter *scr_emitter_on_any(ScrEmitter *em, ScrStr *name, const ScrDyn *cb, + bool once, bool prepend); +bool scr_emitter_emit_any(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc); ScrEmitter *scr_emitter_off_dyn(ScrEmitter *em, ScrStr *name, const ScrDyn *cb); +/* The registry's checked-dynamic half (meta-event argument vectors and + * rebuilt listener values), installed by the first on_any call; NULL in + * programs that never register a listener through an untyped value. + * Internal. */ +typedef struct ScrEeDynOps ScrEeDynOps; +extern SCR_TL const ScrEeDynOps *scr_ee_dyn; /* The adapter-mediated registration (the LLVM backend's listeners): the * ADAPTER closure is what emit invokes, the ORIGINAL closure is the * entry's identity (the on_dyn split, with a plain-closure identity). @@ -139,6 +154,12 @@ ScrEmitter *scr_emitter_remove_all(ScrEmitter *em, ScrStr *name, bool all); * emit('error', err): no listener ⇒ THROWS err (borrowed; +1 taken). */ bool scr_emitter_emit(ScrEmitter *em, ScrStr *name, ...); bool scr_emitter_emit_flex(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc); +/* An untyped emit of an event with a fixed tuple: the arguments as given + * for dynamic listeners, plus the tuple converted by the caller as + * scr_emitter_emit's variadics for typed listeners. has_typed answers + * whether any listener of the name needs the converted tuple. */ +bool scr_emitter_emit_mixed(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc, ...); +bool scr_emitter_has_typed(ScrEmitter *em, ScrStr *name); bool scr_emitter_emit_error_flex(ScrEmitter *em, ScrDyn *error); bool scr_emitter_emit_error(ScrEmitter *em, ScrStr *name, ScrError *err); double scr_emitter_listener_count(ScrEmitter *em, ScrStr *name); @@ -154,6 +175,7 @@ ScrStr *scr_emitter_error_monitor(void); * other read). The stream constructors call this. */ void scr_emitter_reserve(ScrEmitter *em, const char *name); ScrArr *scr_emitter_listeners(ScrEmitter *em, ScrStr *name); /* +1 closures */ +ScrDyn *scr_emitter_listeners_dyn(ScrEmitter *em, ScrStr *name); /* +1 dyn array */ ScrEmitter *scr_emitter_set_max(ScrEmitter *em, double n); /* returns em +1 */ double scr_emitter_get_max(ScrEmitter *em); void scr_emitter_set_default_max(double n); diff --git a/tests/corpus/6580-emitter-typed-untyped-listeners.ts b/tests/corpus/6580-emitter-typed-untyped-listeners.ts new file mode 100644 index 0000000000..42f3db549c --- /dev/null +++ b/tests/corpus/6580-emitter-typed-untyped-listeners.ts @@ -0,0 +1,50 @@ +// One native EventEmitter used both through its static type and through an +// untyped reference: typed and untyped listeners of the same event share +// one listener list, so emits from either side reach every listener in +// registration order, with once, prepend, off and listenerCount over the +// whole list. Untyped emits pass their arguments as given (extra ones +// included) to untyped listeners and the event's typed tuple to typed ones. +import { EventEmitter } from "node:events"; + +const e = new EventEmitter(); +const d: any = e; +const log: string[] = []; +const typed = (n: number, s: string): void => { + log.push(`typed ${n} ${s}`); +}; +e.on("tick", typed); +d.on("tick", (...args: any[]) => { + log.push(`untyped ${JSON.stringify(args)}`); +}); +e.once("tick", (n: number) => { + log.push(`typed once ${n}`); +}); +d.once("tick", (n: any) => { + log.push(`untyped once ${n}`); +}); +d.prependListener("tick", () => { + log.push("untyped first"); +}); +console.log(e.listenerCount("tick"), d.listenerCount("tick")); +console.log(e.emit("tick", 1, "a")); +console.log(d.emit("tick", 2, "b", "extra")); +console.log(e.listenerCount("tick"), d.listenerCount("tick"), d.listenerCount("tick", typed)); +d.off("tick", typed); +e.emit("tick", 3, "c"); +d.emit("tick", 4, "d"); +console.log(log.join("\n")); +log.length = 0; + +// A typed prefix listener next to untyped listeners of a longer tuple. +e.on("pair", (a: string) => log.push(`prefix ${a}`)); +d.on("pair", (a: any, b: any) => log.push(`untyped ${a} ${b}`)); +e.emit("pair", "x", 1); +d.emit("pair", "y", 2); +console.log(log.join("\n")); + +// Names only untyped code uses stay untyped, and an emit nobody hears +// answers false. +d.on("loose", (...args: any[]) => console.log("loose", args.length, args)); +console.log(d.emit("loose"), d.emit("loose", null, [1], { k: true })); +console.log(d.emit("nobody", 1), e.emit("tick", 5, "e")); +console.log(d.eventNames(), e.eventNames()); diff --git a/tests/corpus/6581-emitter-untyped-error-and-meta.ts b/tests/corpus/6581-emitter-untyped-error-and-meta.ts new file mode 100644 index 0000000000..1cdd10bec7 --- /dev/null +++ b/tests/corpus/6581-emitter-untyped-error-and-meta.ts @@ -0,0 +1,59 @@ +// 'error', errorMonitor and the newListener/removeListener meta events on +// an emitter used both through its static type and through an untyped +// reference. Untyped emits of 'error' keep Node's contract: listeners of +// either kind receive the payload, and with none an Error throws itself +// while other payloads throw ERR_UNHANDLED_ERROR. Untyped meta listeners +// receive the event name and the listener function. +import { EventEmitter, errorMonitor } from "node:events"; + +const plain: any = new EventEmitter(); +plain.on("newListener", (name: any, fn: any) => console.log("plain newListener", name, typeof fn)); +plain.on("removeListener", (name: any, fn: any) => console.log("plain removeListener", name, fn.name)); +function named(): void {} +plain.on("x", named); +plain.off("x", named); +plain.emit("newListener", "manual", named); + +const e = new EventEmitter(); +const d: any = e; +d.on("newListener", (name: any) => { + console.log("newListener", name); +}); +d.on("removeListener", (name: any) => { + console.log("removeListener", name); +}); +e.on("removeListener", (name: string) => console.log("typed removeListener", name)); +const tap = (n: any): void => console.log("untyped v", n); +d.on("v", tap); +e.on("v", (n: number) => console.log("typed v", n)); +e.emit("v", 1); +d.emit("v", 2); +d.off("v", tap); +e.emit("v", 3); +d.removeAllListeners("v"); +console.log(d.listenerCount("v")); + +d.removeAllListeners("newListener"); +d.removeAllListeners("removeListener"); +const monitor = (err: Error): void => console.log("monitor", err.message); +e.on(errorMonitor, monitor); +e.on("error", (err: Error) => console.log("typed error", err.message)); +d.on("error", (err: any) => console.log("untyped error", err instanceof Error, err.message)); +d.emit("error", new Error("boom")); +e.emit("error", new Error("bang")); +d.removeAllListeners("error"); +try { + d.emit("error", new Error("unhandled")); +} catch (err: any) { + console.log("threw", err instanceof Error, err.message); +} +e.off(errorMonitor, monitor); +for (const payload of ["just text", 42, undefined, null, false]) { + try { + d.emit("error", payload); + } catch (err: any) { + console.log("threw", err.code, err.message); + } +} +d.on("error", (x: any, more: any) => console.log("untyped got", x, more)); +console.log(d.emit("error", 42, "more"), d.listenerCount("error")); diff --git a/tests/corpus/6582-emitter-class-expression-escapes.cjs b/tests/corpus/6582-emitter-class-expression-escapes.cjs new file mode 100644 index 0000000000..da641f1b63 --- /dev/null +++ b/tests/corpus/6582-emitter-class-expression-escapes.cjs @@ -0,0 +1,61 @@ +// A function-local class expression extending EventEmitter whose +// instances escape untyped: the class body emits with fixed tuples while +// callers listen, emit and inspect through untyped references. +"use strict"; +const { EventEmitter } = require("node:events"); + +function makeQueue(label) { + const Queue = class extends EventEmitter { + constructor() { + super(); + this.items = []; + } + push(item) { + this.items.push(item); + this.emit("added", item, this.items.length); + return this; + } + drain() { + const items = this.items.splice(0); + this.emit("drained", items.length); + return items; + } + fail(message) { + this.emit("error", new Error(`${label}: ${message}`)); + } + }; + return new Queue(); +} + +const q = makeQueue("q1"); +q.on("added", (item, size) => console.log("added", item, size)); +q.once("added", (item) => console.log("first add", item)); +q.prependListener("drained", (count) => console.log("drained", count)); +q.push("a").push("b"); +q.emit("added", "manual", 99, "extra"); +console.log(q.drain()); +q.on("error", (err) => console.log("error", err.message)); +q.fail("broken"); +console.log(q.listenerCount("added"), q.eventNames(), q.getMaxListeners()); +q.removeAllListeners("added"); +q.push("c"); +console.log(q.listenerCount("added"), q.listeners("drained").length); +q.removeAllListeners(); +console.log(q.eventNames()); + +const q2 = makeQueue("q2"); +try { + q2.fail("nobody listens"); +} catch (err) { + console.log("caught", err.message); +} +const counts = []; +const q3 = makeQueue("q3"); +const count = () => counts.push(q3.listenerCount("added")); +q3.on("added", count); +q3.on("added", function self() { + q3.off("added", self); + counts.push("self"); +}); +q3.push(1).push(2); +console.log(counts); diff --git a/tests/corpus/6583-emitter-untyped-surface.ts b/tests/corpus/6583-emitter-untyped-surface.ts new file mode 100644 index 0000000000..18cd3cb91a --- /dev/null +++ b/tests/corpus/6583-emitter-untyped-surface.ts @@ -0,0 +1,47 @@ +// The EventEmitter surface beyond on/once/emit/off called through untyped +// references to compiled emitters (a subclass instance and a plain +// EventEmitter): listenerCount, eventNames, listeners, rawListeners, +// removeAllListeners, setMaxListeners and getMaxListeners answer Node's +// results, and the chaining forms return the emitter. +import { EventEmitter } from "node:events"; + +class Bus extends EventEmitter { + ping(n: number): boolean { + return this.emit("ping", n); + } +} +const b = new Bus(); +const u: unknown = b; +const d = u as any; +const f = (n: any): void => console.log("f", n); +d.on("ping", f); +d.prependOnceListener("ping", (n: any) => console.log("once first", n)); +d.prependListener("ping", (n: any) => console.log("first", n)); +console.log(d.listenerCount("ping"), d.listenerCount("ping", f), d.listenerCount("none")); +console.log(d.eventNames(), d.listenerCount()); +const all = d.listeners("ping"); +console.log(all.length, d.rawListeners("ping").length, all[2] === f, typeof all[0]); +console.log(d.listeners("none")); +console.log(d.getMaxListeners(), d.setMaxListeners(2) === d, b.getMaxListeners()); +console.log(b.ping(1), b.ping(2)); +try { + d.setMaxListeners(-1); +} catch (err: any) { + console.log(err.name, err.code, err.message); +} +try { + d.setMaxListeners("x"); +} catch (err: any) { + console.log(err.name, err.code, err.message); +} +d.on("other", () => {}); +console.log(d.removeAllListeners("ping") === d, d.eventNames()); +console.log(d.removeAllListeners() === d, d.eventNames(), d.listenerCount("other")); +console.log(b.ping(3)); + +const plain: any = new EventEmitter(); +plain.on("x", () => {}); +plain.on("y", () => {}); +console.log(plain.eventNames(), plain.getMaxListeners(), plain.listeners("x").length); +plain.setMaxListeners(0); +console.log(plain.getMaxListeners(), plain.removeAllListeners("x").eventNames()); diff --git a/tests/corpus/6584-emitter-class-body-untyped-listeners.ts b/tests/corpus/6584-emitter-class-body-untyped-listeners.ts new file mode 100644 index 0000000000..f2ca78d847 --- /dev/null +++ b/tests/corpus/6584-emitter-class-body-untyped-listeners.ts @@ -0,0 +1,54 @@ +// A class whose methods emit with fixed tuples, observed by listeners +// registered through untyped references: class-instance payloads keep +// their identity, once/off apply across typed and untyped listeners, a +// listener may capture and remove itself, untyped emits reach events.once, +// and errorMonitor sees untyped 'error' emits. +import { EventEmitter, once, errorMonitor } from "node:events"; + +class Job { + readonly tags: string[] = []; + readonly id: number; + constructor(id: number) { + this.id = id; + } +} + +class Worker extends EventEmitter { + run(job: Job): void { + this.emit("job", job, job.tags.length); + } + finish(): void { + this.emit("done"); + } +} + +const w = new Worker(); +const u: any = w; +const first = new Job(7); +u.on("job", (j: any, count: any) => { + console.log("untyped job", j.id, count, j === first, j instanceof Job); + j.tags.push("seen"); +}); +w.on("job", (j: Job) => console.log("typed job", j.id, j.tags.join(","))); +u.once("done", () => console.log("done once")); +w.run(first); +w.finish(); +w.finish(); +u.emit("job", new Job(8), 0); +const self = (): void => { + console.log("self", u.listenerCount("job")); + u.off("job", self); +}; +u.on("job", self); +w.run(new Job(9)); +w.run(new Job(10)); +w.on(errorMonitor, (err: Error) => console.log("monitor", err.message)); +w.on("error", (err: Error) => console.log("typed error", err.message)); +u.emit("error", new Error("e1")); +async function main(): Promise { + const pending = once(w, "job"); + u.emit("job", new Job(11), 1); + const [j, count]: any[] = await pending; + console.log("once resolved", j.id, count); +} +main(); From 0cf62ebecfe8e25cc00971ec2c17988186a5624f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 17:36:37 -0700 Subject: [PATCH 2/5] Answer EventEmitter methods read as values off compiled emitters Reading an EventEmitter.prototype method off a compiled emitter (a program class extending EventEmitter, or new EventEmitter()) through an untyped value answered undefined (`typeof o.on`, `const f = o.on; f.call(o, ...)`, `o.emit.bind(o)`), and EventEmitter.prototype.on in JavaScript was a fence. Such reads, typed value reads, computed keys, `in`, and EventEmitter.prototype. now answer the runtime's EventEmitter.prototype functions, the ones constructor-function emitters inherit, so `o.on === o.addListener === EventEmitter.prototype.on` and names and lengths match Node. The first read installs a program dispatch that those functions call for a compiled emitter receiver; it invokes the method through the existing untyped dispatch with the call's argument count (emit forwards up to 16 arguments after the name, then fences). An untyped emit on, or read of emit from, a class that overrides emit now fences instead of bypassing the override for the native emit. A subclass visited under the boxed EventEmitter capsule keeps its own dispatch plan for values boxed as itself. Co-Authored-By: Claude Opus 5.5 --- .../limitations/builtin-apis/eventemitter.mdx | 5 +- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../lowering/class-dynamic-dispatch.ts | 67 +++- .../lowering/emitter-method-values.ts | 303 ++++++++++++++++++ .../lowering/expressions/property-access.ts | 10 + .../compiler/src/ir/builtin-signatures.ts | 4 + packages/compiler/src/ir/runtime-features.ts | 2 + ...6600-emitter-method-values-untyped.ts.json | 6 + .../6601-emitter-method-values.cjs.json | 6 + .../6602-emitter-method-values-typed.ts.json | 6 + packages/runtime/src/scr_dyn.h | 5 + packages/runtime/src/scr_dyn_builtin_class.c | 45 ++- .../6600-emitter-method-values-untyped.ts | 75 +++++ tests/corpus/6601-emitter-method-values.cjs | 73 +++++ .../6602-emitter-method-values-typed.ts | 34 ++ 15 files changed, 638 insertions(+), 4 deletions(-) create mode 100644 packages/compiler/src/frontend/lowering/emitter-method-values.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6600-emitter-method-values-untyped.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6601-emitter-method-values.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6602-emitter-method-values-typed.ts.json create mode 100644 tests/corpus/6600-emitter-method-values-untyped.ts create mode 100644 tests/corpus/6601-emitter-method-values.cjs create mode 100644 tests/corpus/6602-emitter-method-values-typed.ts diff --git a/docs/content/limitations/builtin-apis/eventemitter.mdx b/docs/content/limitations/builtin-apis/eventemitter.mdx index 0e085c56f9..e6c140cde8 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 007c78c9e1..3f071d27ca 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 c9e6768a6e..af2a61213a 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 0000000000..f04957e115 --- /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 36ea0e8c22..1e27221146 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 d181927fc6..9bba571673 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 fd7c98cf6e..5c38b14f98 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 0000000000..260282e750 --- /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 0000000000..13f1aaa7da --- /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 0000000000..409f1eaa97 --- /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 401bf2b392..14734ab314 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 2f947048a2..70a67c5596 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 0000000000..1675c97d6e --- /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 0000000000..7873b5f8e0 --- /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 0000000000..45fb7f1fcc --- /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")); From 3a27a92cb4549ce6cbe58f89efcac9ff8b7b6cb9 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 18:22:43 -0700 Subject: [PATCH 3/5] Forward at most four emit arguments through EventEmitter method values The dispatch instantiates one bridged emit per argument count and emitter class; capping it at four keeps programs that only read emitter members through computed keys from growing by the full table. Co-Authored-By: Claude Opus 5.5 --- docs/content/limitations/builtin-apis/eventemitter.mdx | 2 +- .../compiler/src/frontend/lowering/emitter-method-values.ts | 2 +- tests/corpus/6600-emitter-method-values-untyped.ts | 2 +- tests/corpus/6601-emitter-method-values.cjs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/content/limitations/builtin-apis/eventemitter.mdx b/docs/content/limitations/builtin-apis/eventemitter.mdx index e6c140cde8..8539b814f9 100644 --- a/docs/content/limitations/builtin-apis/eventemitter.mdx +++ b/docs/content/limitations/builtin-apis/eventemitter.mdx @@ -6,7 +6,7 @@ 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. 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.emit` as a function value on a compiled emitter with more than 4 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. diff --git a/packages/compiler/src/frontend/lowering/emitter-method-values.ts b/packages/compiler/src/frontend/lowering/emitter-method-values.ts index f04957e115..7b4992bee1 100644 --- a/packages/compiler/src/frontend/lowering/emitter-method-values.ts +++ b/packages/compiler/src/frontend/lowering/emitter-method-values.ts @@ -40,7 +40,7 @@ export const EMITTER_PROTOTYPE_METHODS: ReadonlySet = new Set([ /** `emit` forwards this many arguments after the event name; a call with * more fences. */ -const EMIT_ARGUMENTS = 16; +const EMIT_ARGUMENTS = 4; /** The runtime's SCR_EE_M_* order: each method's name and the argument * counts its call forwards. Node's methods that look at diff --git a/tests/corpus/6600-emitter-method-values-untyped.ts b/tests/corpus/6600-emitter-method-values-untyped.ts index 1675c97d6e..f25be85d90 100644 --- a/tests/corpus/6600-emitter-method-values-untyped.ts +++ b/tests/corpus/6600-emitter-method-values-untyped.ts @@ -40,7 +40,7 @@ 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(emit("many", 1, 2, 3, 4)); console.log(o.setMaxListeners.call(o, 3) === o, o.getMaxListeners.call(o)); // off through a detached method value diff --git a/tests/corpus/6601-emitter-method-values.cjs b/tests/corpus/6601-emitter-method-values.cjs index 7873b5f8e0..e462bda8eb 100644 --- a/tests/corpus/6601-emitter-method-values.cjs +++ b/tests/corpus/6601-emitter-method-values.cjs @@ -38,7 +38,7 @@ 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)); +console.log(emit.call(plain, "hi", 1, 2, 3, 4)); const fn = () => console.log("never"); plain.once("x", fn); const off = plain.off; From 3f3e90b84618b636094d6c23d80e4c059b96318a Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 21 Oct 2026 07:53:10 -0700 Subject: [PATCH 4/5] Drop the EventEmitter method-values follow-up Revert 3a27a92cb ("Forward at most four emit arguments through EventEmitter method values") and 0cf62ebec ("Answer EventEmitter methods read as values off compiled emitters"). Under the scope-B plan, EventEmitter methods read as values, `EventEmitter.prototype.on` in JavaScript and constructor-function emitters belong to Node islands, not the native compiler. The typed/untyped listener sharing and the catchable throw in place of the trap stay. Co-Authored-By: Claude Opus 5.5 --- .../limitations/builtin-apis/eventemitter.mdx | 5 +- .../compiler/src/backend/llvm/lib-shared.ts | 1 - .../lowering/class-dynamic-dispatch.ts | 67 +--- .../lowering/emitter-method-values.ts | 303 ------------------ .../lowering/expressions/property-access.ts | 10 - .../compiler/src/ir/builtin-signatures.ts | 4 - packages/compiler/src/ir/runtime-features.ts | 2 - ...6600-emitter-method-values-untyped.ts.json | 6 - .../6601-emitter-method-values.cjs.json | 6 - .../6602-emitter-method-values-typed.ts.json | 6 - packages/runtime/src/scr_dyn.h | 5 - packages/runtime/src/scr_dyn_builtin_class.c | 45 +-- .../6600-emitter-method-values-untyped.ts | 75 ----- tests/corpus/6601-emitter-method-values.cjs | 73 ----- .../6602-emitter-method-values-typed.ts | 34 -- 15 files changed, 4 insertions(+), 638 deletions(-) delete mode 100644 packages/compiler/src/frontend/lowering/emitter-method-values.ts delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6600-emitter-method-values-untyped.ts.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6601-emitter-method-values.cjs.json delete mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6602-emitter-method-values-typed.ts.json delete mode 100644 tests/corpus/6600-emitter-method-values-untyped.ts delete mode 100644 tests/corpus/6601-emitter-method-values.cjs delete mode 100644 tests/corpus/6602-emitter-method-values-typed.ts diff --git a/docs/content/limitations/builtin-apis/eventemitter.mdx b/docs/content/limitations/builtin-apis/eventemitter.mdx index 8539b814f9..0e085c56f9 100644 --- a/docs/content/limitations/builtin-apis/eventemitter.mdx +++ b/docs/content/limitations/builtin-apis/eventemitter.mdx @@ -5,9 +5,6 @@ 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. - 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 4 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`. + Reading EventEmitter methods as properties through an untyped value (`typeof emitter.listeners`) answers `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 3f071d27ca..007c78c9e1 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1465,7 +1465,6 @@ 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 af2a61213a..c9e6768a6e 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -74,12 +74,6 @@ 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"; @@ -2777,7 +2771,6 @@ 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" && @@ -2896,12 +2889,6 @@ 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) @@ -3114,8 +3101,6 @@ 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()]; @@ -3563,10 +3548,6 @@ 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; } @@ -4327,9 +4308,7 @@ export class ClassDynamicDispatch { (!canonical || !seen.has(info.def.name)) && (info === capsule || lowerer.classCanBeConstructed(info)) ) { - // 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); + seen.add(info.def.name); plans.push({ info, capsule: canonical ? info : capsule, @@ -4749,45 +4728,6 @@ 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); @@ -5219,11 +5159,6 @@ 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 deleted file mode 100644 index 7b4992bee1..0000000000 --- a/packages/compiler/src/frontend/lowering/emitter-method-values.ts +++ /dev/null @@ -1,303 +0,0 @@ -/** - * 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 = 4; - -/** 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 1e27221146..36ea0e8c22 100644 --- a/packages/compiler/src/frontend/lowering/expressions/property-access.ts +++ b/packages/compiler/src/frontend/lowering/expressions/property-access.ts @@ -128,10 +128,6 @@ 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"; @@ -333,12 +329,6 @@ 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 9bba571673..d181927fc6 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3611,10 +3611,6 @@ 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 5c38b14f98..fd7c98cf6e 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -259,8 +259,6 @@ 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 deleted file mode 100644 index 260282e750..0000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6600-emitter-method-values-untyped.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 deleted file mode 100644 index 13f1aaa7da..0000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6601-emitter-method-values.cjs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 deleted file mode 100644 index 409f1eaa97..0000000000 --- a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6602-emitter-method-values-typed.ts.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 14734ab314..401bf2b392 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1159,11 +1159,6 @@ 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 70a67c5596..2f947048a2 100644 --- a/packages/runtime/src/scr_dyn_builtin_class.c +++ b/packages/runtime/src/scr_dyn_builtin_class.c @@ -66,35 +66,9 @@ static ScrDyn *bc_class(int kind); /* The stream half, once registered (scr_dyn_builtin_stream.c). */ static SCR_TL const ScrDynStreamOps *bc_stream_ops; -/* 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. */ +/* EventEmitter.prototype's method on a receiver with stream state. */ static bool bc_stream_event(int method, ScrDyn *self, ScrDyn *const *args, size_t argc, ScrDyn **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; + return bc_stream_ops != NULL && bc_stream_ops->event(method, self, args, argc, result); } /* ── property helpers ───────────────────────────────────────────────── */ @@ -1462,8 +1436,6 @@ 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(); } @@ -1570,19 +1542,6 @@ 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 deleted file mode 100644 index f25be85d90..0000000000 --- a/tests/corpus/6600-emitter-method-values-untyped.ts +++ /dev/null @@ -1,75 +0,0 @@ -// 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)); -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 deleted file mode 100644 index e462bda8eb..0000000000 --- a/tests/corpus/6601-emitter-method-values.cjs +++ /dev/null @@ -1,73 +0,0 @@ -"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)); -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 deleted file mode 100644 index 45fb7f1fcc..0000000000 --- a/tests/corpus/6602-emitter-method-values-typed.ts +++ /dev/null @@ -1,34 +0,0 @@ -// 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")); From cd38fd2248fefeddcff452670bac65d07e9a129f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 11:03:19 -0500 Subject: [PATCH 5/5] Bridge events whose typed listeners take no parameters An event that typed code only listened to with a listener taking no parameters had no bridge entry: the scan marked a listener as typed only when one of its parameters was not a checked-dynamic value. Emitting the event through an `any` reference then threw instead of calling the listener. A TypeScript listener with no parameters now marks the event as typed. A JavaScript function without parameters still registers as before, since it can read `arguments`. --- .../frontend/lowering/lower-event-emitter.ts | 8 ++++++- ...-emitter-untyped-zero-arg-listener.ts.json | 6 +++++ .../6941-emitter-untyped-zero-arg-listener.ts | 23 +++++++++++++++++++ ...-emitter-untyped-zero-arg-listener.ts.json | 1 + 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts.json create mode 100644 tests/corpus/6941-emitter-untyped-zero-arg-listener.ts create mode 100644 tests/harness/corpus-costs/6941-emitter-untyped-zero-arg-listener.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index 6b79666b1b..d84f84f4ee 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -338,9 +338,15 @@ function emitterEvents(lowerer: Lowerer): Map { const cbCt = lowerer.typeOf(node.arguments.nodes[1]); const cbT = lowerer.mapTypeOf(cbCt) ?? dynFallbackType(lowerer, node.arguments.nodes[1], cbCt); + // A TypeScript listener with no parameters is typed too: it + // registers without the adapter, so an untyped emit reaches it + // only through the event's fixed ABI. A JavaScript function + // without parameters can still read `arguments`. if ( cbT?.kind !== "dyn" && - (cbT?.kind !== "func" || cbT.params.some((p) => p.kind !== "dyn")) + (cbT?.kind !== "func" || + (cbT.params.length === 0 && !isJsSourceFile(sf)) || + cbT.params.some((p) => p.kind !== "dyn")) ) { sigOf(name).typedListener = true; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts.json new file mode 100644 index 0000000000..5e0afea43e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts b/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts new file mode 100644 index 0000000000..89c4dc70f3 --- /dev/null +++ b/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts @@ -0,0 +1,23 @@ +// Emitting through an `any` reference reaches listeners that typed code +// registered with no parameters, for an event no typed code emits: they +// run once per emit, whatever arguments the untyped emit passes. + +import { EventEmitter } from "node:events"; + +const emitter = new EventEmitter(); +let ready = 0; +emitter.on("ready", () => { + ready++; + console.log("ready", ready); +}); +emitter.once("closed", function () { + console.log("closed once"); +}); + +const loose: any = emitter; +console.log(loose.emit("ready")); +console.log(loose.emit("ready", 1, "two")); +console.log(loose.emit("closed", { reason: "done" })); +console.log(loose.emit("closed")); +console.log(loose.emit("missing")); +console.log(emitter.listenerCount("ready"), emitter.listenerCount("closed"), ready); diff --git a/tests/harness/corpus-costs/6941-emitter-untyped-zero-arg-listener.ts.json b/tests/harness/corpus-costs/6941-emitter-untyped-zero-arg-listener.ts.json new file mode 100644 index 0000000000..21b7842503 --- /dev/null +++ b/tests/harness/corpus-costs/6941-emitter-untyped-zero-arg-listener.ts.json @@ -0,0 +1 @@ +{ "plain": 2800, "san": 3400 }