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 c95a1f14ad..699e52575a 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -853,7 +853,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"; @@ -867,10 +867,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 76b6e88f59..5130b26b10 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1068,6 +1068,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", @@ -1076,6 +1077,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 cb5c284449..fb4ce2bf35 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 d768cec9b4..a92871db86 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -4090,6 +4090,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..d84f84f4ee 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"; @@ -336,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; } @@ -931,6 +939,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 b783a6b9fc..ca8c521186 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -238,12 +238,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 eb2718616c..7e142c8af3 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3756,16 +3756,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 9635ceb91e..53836dad1f 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4080,6 +4080,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" || @@ -5847,6 +5849,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" || @@ -5901,14 +5904,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/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/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(); 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 }