Support non-Error 'error' payloads and checked-dynamic listeners in listeners() and meta events

This commit is contained in:
Chris Tate
2026-10-29 19:16:06 -05:00
parent 0f684ad25f
commit 48dd898db6
31 changed files with 614 additions and 96 deletions
@@ -1,10 +1,11 @@
- **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.
Unrestricted names, symbols, and names that may reach internal events 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.
A `newListener` or `removeListener` listener registered through an untyped value receives `undefined` in place of a listener registered with typed parameters.
A typed `error` or `errorMonitor` listener throws a `TypeError` when an emitted payload that is not an Error does not convert to its parameter.
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.
@@ -951,6 +951,34 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
host.emitPendingCheck();
return { name: t, type: e.type };
}
if (e.fn === "emitter.emitErrorValue") {
// (recv, ...payload as dyn): emit('error') with a non-Error payload.
// The unhandled case renders the payload with util.inspect
// (scr_insp_dyn), which this call links.
const args = e.args.map((a) => host.emitExpr(a));
const n = args.length - 1;
let payload = "null";
if (n > 0) {
const arr = B.slot();
B.entryAllocas.push(`${arr} = alloca [${n} x ptr]`);
for (let i = 0; i < n; i++) {
const slot = B.tmp();
B.line(`${slot} = getelementptr inbounds [${n} x ptr], ptr ${arr}, i64 0, i64 ${i}`);
B.line(`store ptr ${args[i + 1]!.name}, ptr ${slot}`);
}
payload = arr;
}
host.declare(`declare ptr @scr_insp_dyn(ptr, double, double)`);
host.declare(
`declare zeroext i1 @scr_emitter_emit_error_value(ptr, ptr, ${host.sizeType}, ptr)`,
);
const t = B.tmp();
B.line(
`${t} = call i1 @scr_emitter_emit_error_value(ptr ${args[0]!.name}, ptr ${payload}, ${host.sizeType} ${n}, ptr @scr_insp_dyn)`,
);
host.emitPendingCheck();
return { name: t, type: e.type };
}
if (e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny") {
const args = e.args.map((a) => host.emitExpr(a));
const n = args.length - 2;
@@ -44,6 +44,7 @@
* super.emit as the prototype chain). */
import { keyedHelperName } from "./stable-names.js";
import { checkKeypressListener } from "./builtins/readline.js";
import { stripTypeCasts } from "./builtins/arguments.js";
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
import { dynFallbackType, newFnCtx } from "./lowerer.js";
@@ -120,6 +121,54 @@ export interface EventSig {
unmappedEmit?: true;
/** A listener with any concrete parameter still needs the fixed ABI. */
typedListener?: true;
/** listeners(), rawListeners() or events.getEventListeners() reads the
* event's listener array. With a dyn-flavored listener present, every
* registration of the event goes through the dyn adapter, so the array
* holds checked-dynamic function values (listenerArrayIsDyn). */
listenersRead?: true;
/** ('error' only) An emit passes a payload that is not statically an
* Error, which typed listeners of 'error' and errorMonitor receive as a
* checked-dynamic value. */
valueEmit?: true;
}
/** Whether `t` is an instance type of the Error hierarchy. */
function rootsAtError(lowerer: Lowerer, t: IrType | null): boolean {
if (t?.kind !== "object") return false;
for (let c: ClassInfo | null = lowerer.classes.get(t.className) ?? null; c; c = c.base) {
if (c.def.name === "%Error") return true;
}
return false;
}
/** Whether a typed listener of `name` registers through the dyn adapter, so
* its entry holds a checked-dynamic function value: a dyn-flavored
* 'newListener' or 'removeListener' listener receives every added or
* removed listener as one, and a read listener array beside dyn-flavored
* listeners holds them. Listeners of 'error' and errorMonitor do when an
* emit passes a payload that is not an Error: the adapter's checked
* conversion admits it or throws a TypeError. */
function registersAsDyn(lowerer: Lowerer, name: string, streamForced: boolean): boolean {
const table = emitterEvents(lowerer);
if ([...META_EVENTS].some((meta) => table.get(meta)?.dynListener === true)) return true;
if ((name === "error" || name === ERROR_MONITOR_EVENT) && table.get("error")?.valueEmit === true)
return true;
const sig = streamForced ? undefined : table.get(name);
return sig?.dynListener === true && sig.listenersRead === true;
}
/** Whether the event's listener array is read as checked-dynamic values:
* its registrations all hold a checked-dynamic function value (flexible
* and computed-name events, or a dyn-flavored listener beside a read of
* the array). */
function listenerArrayIsDyn(lowerer: Lowerer, name: string, sig: EventSig | undefined): boolean {
return (
(sig?.dynListener === true && sig.listenersRead === true) ||
flexibleJsEvent(name, sig) ||
computedPatterns(lowerer).some(
(pattern) => pattern.listener && patternMatchesName(pattern, name),
)
);
}
function flexibleJsEvent(name: string, sig: EventSig | undefined): boolean {
@@ -286,12 +335,48 @@ function emitterEvents(lowerer: Lowerer): Map<string, EventSig> {
}
};
// Events whose listener array is read; applied to the table after the
// walk, so a read alone adds no entry.
const listenersRead = new Set<string>();
for (const sf of lowerer.program.getImplementationSourceFiles()) {
if (sf.isDeclarationFile) continue;
const walk = (node: ts.Node): void => {
ts.forEachChild(node, walk);
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return;
const member = node.expression.name.text;
if (!ts.isCallExpression(node)) return;
const callee = node.expression;
if (
(ts.isIdentifier(callee) || ts.isPropertyAccessExpression(callee)) &&
(ts.isIdentifier(callee) ? callee : callee.name).text === "getEventListeners" &&
node.arguments.nodes.length === 2
) {
const nameT = (() => {
try {
return lowerer.typeOf(node.arguments.nodes[1]!);
} catch {
return null;
}
})();
if (nameT?.isStringLiteralType()) listenersRead.add(literalEventKey(nameT.value));
return;
}
if (!ts.isPropertyAccessExpression(callee)) return;
const member = callee.name.text;
if (member === "listeners" || member === "rawListeners") {
const arg = node.arguments.nodes[0];
let nameT: ts.Type | null = null;
try {
if (
!arg ||
!scannedEmitterInfo(lowerer, lowerer.mapTypeOf(lowerer.typeOf(callee.expression)))
)
return;
nameT = lowerer.typeOf(arg);
} catch {
return;
}
if (nameT.isStringLiteralType()) listenersRead.add(literalEventKey(nameT.value));
return;
}
const isEmit = member === "emit";
if (!isEmit && !REGISTER_MEMBERS.has(member)) return;
const arg0 = node.arguments.nodes[0];
@@ -299,7 +384,7 @@ function emitterEvents(lowerer: Lowerer): Map<string, EventSig> {
let recvT: IrType | null = null;
let nameT: ts.Type | null = null;
try {
recvT = lowerer.mapTypeOf(lowerer.typeOf(node.expression.expression));
recvT = lowerer.mapTypeOf(lowerer.typeOf(callee.expression));
nameT = lowerer.typeOf(arg0);
} catch {
return; // checker trouble is the lowering's business, not the scan's
@@ -323,6 +408,46 @@ function emitterEvents(lowerer: Lowerer): Map<string, EventSig> {
return;
}
const name = literal;
if (META_EVENTS.has(name) && !isEmit && node.arguments.nodes[1]) {
// A dyn-flavored meta listener receives each added or removed
// listener as a checked-dynamic function value. The scan cannot
// lower the listener as dynListenerFlavor does, so it counts every
// listener that is not statically a typed function: a factory's
// result (a wrapper whose runtime value is checked-dynamic), a
// JavaScript function without parameters, and the dyn-flavored
// forms. Counting one too many only routes more registrations
// through the adapter.
const cbNode = node.arguments.nodes[1];
let typed = false;
try {
const cbCt = lowerer.typeOf(cbNode);
const cbT = lowerer.mapTypeOf(cbCt) ?? dynFallbackType(lowerer, cbNode, cbCt);
typed =
cbT?.kind === "func" &&
!ts.isCallExpression(stripTypeCasts(cbNode)) &&
!(cbT.params.length === 0 && isJsSourceFile(sf)) &&
!cbT.params.some((p) => p.kind === "dyn") &&
!readsArgumentsPack(lowerer, cbNode);
} catch {
typed = false;
}
if (!typed) table.get(name)!.dynListener = true;
return;
}
if (isEmit && name === "error") {
try {
const payload = node.arguments.nodes.slice(1);
if (
payload.length !== 1 ||
!rootsAtError(lowerer, lowerer.mapTypeOf(lowerer.typeOf(payload[0]!)))
) {
table.get("error")!.valueEmit = true;
}
} catch {
table.get("error")!.valueEmit = true;
}
return;
}
if (META_EVENTS.has(name) || name === "error" || name === ERROR_MONITOR_EVENT) return; // forced tuples
// Stream receivers' runtime-emitted events carry PER-BASE forced
// tuples (lower-stream.ts) — their sites never join the program-
@@ -377,6 +502,10 @@ function emitterEvents(lowerer: Lowerer): Map<string, EventSig> {
};
walk(sf);
}
for (const name of listenersRead) {
const sig = table.get(name);
if (sig) sig.listenersRead = true;
}
lowerer.emitterEventTable = table;
lowerer.emitterComputedPatterns = computedPatterns;
return table;
@@ -604,6 +733,7 @@ function lowerFlexListenerCall(
once: boolean,
prepend: boolean,
loc: SrcLoc,
onFn: "emitter.onFlex" | "emitter.onAny" = "emitter.onFlex",
): IrExpr {
if (receiver.type.kind !== "object") {
lowerer.noLowering(
@@ -633,10 +763,10 @@ function lowerFlexListenerCall(
lowerer.noLowering(`${member} with a non-function computed-name listener`, cbNode);
}
const recvT = receiver.type;
const key = `emitter.${registering ? "onFlex" : "offDyn"}:${typeKey(recvT)}`;
const key = `${registering ? onFn : "emitter.offDyn"}:${typeKey(recvT)}`;
let helper = lowerer.arrHofHelpers.get(key);
if (!helper) {
helper = keyedHelperName(lowerer, `%emitter.${registering ? "onFlex" : "offFlex"}`, key);
helper = keyedHelperName(lowerer, `%${registering ? onFn : "emitter.offFlex"}`, key);
lowerer.arrHofHelpers.set(key, helper);
const params: IrParam[] = [
{ localId: "r.0", name: "r", type: recvT },
@@ -671,7 +801,7 @@ function lowerFlexListenerCall(
kind: "return",
value: {
kind: "libCall",
fn: registering ? "emitter.onFlex" : "emitter.offDyn",
fn: registering ? onFn : "emitter.offDyn",
args: [
varRef("r.0", recvT, loc),
varRef("n.0", STRING, loc),
@@ -699,6 +829,59 @@ function lowerFlexListenerCall(
loc,
};
}
/** A lowered value as a checked-dynamic payload: boxed as any untyped
* value is (records and arrays keep their identity), otherwise a refusal
* at the argument. */
function boxedPayload(lowerer: Lowerer, value: IrExpr, node: ts.Expression): IrExpr {
if (value.type.kind === "dyn") return value;
if (
value.kind === "unitLit" ||
canConvertToDyn(
value.type,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
)
) {
return lowerer.coerceToExpected(value, DYN);
}
lowerer.noLowering(
`emit('error') with a '${lowerer.fmt(value.type)}' payload`,
node,
"the payload cannot box into a dynamic value",
);
}
/** The listener array of an event whose registrations all hold a checked-
* dynamic function value (listenerArrayIsDyn), each comparing equal to the
* function that was registered. JavaScript sources read the untyped array,
* whose functions take every argument a call passes. TypeScript sources
* read the element type the typed path declares, the event's tuple: a
* listener of exactly that signature unwraps to the original closure. */
function dynListenerArray(
lowerer: Lowerer,
table: Map<string, EventSig>,
receiver: IrExpr,
name: string,
call: ts.CallExpression,
loc: SrcLoc,
): IrExpr {
const array: IrExpr = {
kind: "libCall",
fn: "emitter.listenersDyn",
args: [receiver, eventNameExpr(name, loc)],
type: DYN,
loc,
};
if (isJsSourceFile(ts.getSourceFileOfNode(call))) return array;
const tuple = tupleOf(lowerer, table, name, call);
return {
kind: "dynCheck",
value: array,
type: arrayOf({ kind: "func", params: tuple, ret: { kind: "void" } }),
loc,
};
}
/** The event's unified tuple, with conflicts reported at this site. */
function tupleOf(
lowerer: Lowerer,
@@ -820,10 +1003,29 @@ function lowerDynListenerCall(
streamData: boolean,
): IrExpr {
if (META_EVENTS.has(name)) {
lowerer.noLowering(
`'${member}' of '${name}' with a checked-dynamic listener`,
// The registration installs the registry's dynamic half: the listener
// receives the event name and the added or removed listener's
// checked-dynamic function value, as in Node. Every other listener
// then registers through the adapter (registersAsDyn), which the event
// table's scan arranges.
if (registering && emitterEvents(lowerer).get(name)?.dynListener !== true) {
lowerer.noLowering(
`'${member}' of '${name}' with a checked-dynamic listener the event table did not record`,
cbNode,
"register the listener directly at this call",
);
}
return lowerFlexListenerCall(
lowerer,
member,
eventNameExpr(name, loc),
receiver,
cbNode,
"Node passes the listener FUNCTION as the meta event's second argument, which has no dynamic conversion — annotate the listener's parameter as a string",
registering,
once,
prepend,
loc,
"emitter.onAny",
);
}
const getRecord = (id: string) => lowerer.shapes.get(id);
@@ -1490,7 +1692,9 @@ export function lowerEmitterMethodCall(
// to the declared type, or take the value (dyn).
const sides = streamSidesOf(lowerer, info);
if (name === "data" && (sides === "r" || sides === "rw")) {
const dynFlavor = dynListenerFlavor(lowerer, args[1]!);
const dynFlavor =
dynListenerFlavor(lowerer, args[1]!) ??
(registersAsDyn(lowerer, name, true) ? "func" : null);
if (dynFlavor !== null) {
return lowerDynListenerCall(
lowerer,
@@ -1575,7 +1779,11 @@ export function lowerEmitterMethodCall(
const probe = tryLowerExpression(lowerer, args[1]!);
if (probe) checkKeypressListener(lowerer, args[1]!, probe);
}
const dynFlavor = dynListenerFlavor(lowerer, args[1]!) ?? (keypress ? "func" : null);
const dynFlavor =
dynListenerFlavor(lowerer, args[1]!) ??
(keypress || registersAsDyn(lowerer, name, streamForcedTuple(lowerer, info, name) !== null)
? "func"
: null);
if (dynFlavor !== null) {
return lowerDynListenerCall(
lowerer,
@@ -1654,29 +1862,36 @@ export function lowerEmitterMethodCall(
);
const receiver = lowerReceiver();
if (name === "error") {
// The special event: exactly one %Error-rooted payload; no listener
// means the runtime THROWS it (emitter.emitError, may-throw).
if (args.length !== 2) {
lowerer.noLowering(
`emit('error') with ${args.length - 1} payload arguments`,
call,
"the supported form is emit('error', err) with an Error-hierarchy payload (non-Error payloads would need Node's ERR_UNHANDLED_ERROR wrapping — no lowering yet)",
);
}
const err = lowerer.lowerExpr(args[1]!);
const rootsAtError = (t: IrType): boolean => {
if (t.kind !== "object") return false;
for (let c: ClassInfo | null = lowerer.classes.get(t.className) ?? null; c; c = c.base) {
if (c.def.name === "%Error") return true;
// The special event: one %Error-rooted payload; no listener means the
// runtime THROWS it (emitter.emitError, may-throw).
const err = args.length === 2 ? lowerer.lowerExpr(args[1]!) : null;
if (err === null || !rootsAtError(lowerer, err.type)) {
// Any other payload, or another argument count: the arguments box
// to dyn. With no 'error' listener the runtime throws Node's
// ERR_UNHANDLED_ERROR (the payload's util.inspect in the message,
// the payload as `context`); listeners receive the arguments as
// given.
if (emitOverrideAtOrAbove(info) || superRecv !== undefined) {
lowerer.noLowering(
`emit('error') with a non-Error payload through an emit override`,
call,
"the override specialization takes the event's one Error payload",
);
}
return false;
};
if (!rootsAtError(err.type)) {
lowerer.noLowering(
`emit('error') with a '${lowerer.fmt(err.type)}' payload`,
args[1]!,
"the supported payload is an Error-hierarchy instance",
);
const payload = args
.slice(1)
.map((arg, i) =>
i === 0 && err !== null
? boxedPayload(lowerer, err, arg)
: lowerer.lowerExprExpecting(arg, DYN),
);
return {
kind: "libCall",
fn: "emitter.emitErrorValue",
args: [receiver, ...payload],
type: BOOL,
loc,
};
}
return emitDispatchExpr(
lowerer,
@@ -1824,13 +2039,10 @@ export function lowerEmitterMethodCall(
}
const name = eventNameOf(lowerer, member, args[0]!);
const sig = table.get(name);
if (
sig?.dynListener ||
flexibleJsEvent(name, sig) ||
computedPatterns(lowerer).some(
(pattern) => pattern.listener && patternMatchesName(pattern, name),
)
) {
if (listenerArrayIsDyn(lowerer, name, sig)) {
return dynListenerArray(lowerer, table, lowerReceiver(), name, call, loc);
}
if (sig?.dynListener) {
// A dyn-adapted registration means the runtime bucket can hold
// originals of MIXED signatures — no one honest element type.
lowerer.noLowering(
@@ -2809,13 +3021,10 @@ export function lowerEventsEmitterHelper(
const name = eventNameOf(lowerer, "events.getEventListeners", args[1]!);
const table = emitterEvents(lowerer);
const sig = table.get(name);
if (
sig?.dynListener ||
flexibleJsEvent(name, sig) ||
computedPatterns(lowerer).some(
(pattern) => pattern.listener && patternMatchesName(pattern, name),
)
) {
if (listenerArrayIsDyn(lowerer, name, sig)) {
return dynListenerArray(lowerer, table, receiver, name, call, loc);
}
if (sig?.dynListener) {
lowerer.noLowering(
`events.getEventListeners of the event '${name}'`,
call,
@@ -317,6 +317,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"emitter.emit",
"emitter.emitFlex",
"emitter.emitAny",
"emitter.emitErrorValue",
"emitter.emitMixed",
"emitter.listenersDyn",
"emitter.emitError",
@@ -4349,6 +4349,9 @@ function buildLibFnSignatures() {
"emitter.emit": { argTypes: [null, STRING], result: BOOL },
"emitter.emitFlex": { argTypes: [null, STRING], result: BOOL },
"emitter.emitAny": { argTypes: [null, STRING], result: BOOL },
/** emit('error', ...payload) with a non-Error payload: (recv, then the
* arguments boxed) — VARIADIC like emitFlex. */
"emitter.emitErrorValue": { argTypes: [null], 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
@@ -1121,6 +1121,8 @@ const RUNTIME_FEATURE_RULES = {
"webstream.class",
"webstream.install",
"util.styleText",
// ERR_UNHANDLED_ERROR quotes util.inspect of a non-Error payload.
"emitter.emitErrorValue",
"crypto.cipherNew",
"webcrypto.encrypt",
"webcrypto.decrypt",
+8
View File
@@ -4154,6 +4154,7 @@ function validateFunction(
e.fn === "emitter.emit" ||
e.fn === "emitter.emitFlex" ||
e.fn === "emitter.emitAny" ||
e.fn === "emitter.emitErrorValue" ||
e.fn === "emitter.emitMixed" ||
e.fn === "readable.new" ||
e.fn === "writable.new" ||
@@ -6191,6 +6192,12 @@ function validateFunction(
err(`libCall ${e.fn} payloads must be checked-dynamic`, e.loc);
}
}
if (
e.fn === "emitter.emitErrorValue" &&
e.args.slice(1).some((arg) => arg.type.kind !== "dyn")
) {
err(`libCall emitter.emitErrorValue 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;
@@ -6204,6 +6211,7 @@ function validateFunction(
e.fn === "emitter.emit" ||
e.fn === "emitter.emitFlex" ||
e.fn === "emitter.emitAny" ||
e.fn === "emitter.emitErrorValue" ||
e.fn === "emitter.emitMixed" ||
e.fn === "emitter.hasTyped" ||
e.fn === "emitter.listenersDyn" ||
@@ -17,17 +17,6 @@ test.each([
const ee = new Derived(); ee.on('value', (value) => console.log(value)); ee.emit('value'); ee.emit('value', 1);`,
"variable-arity emit through an EventEmitter subclass overriding emit",
],
[
"listener arrays",
`const ee = new EventEmitter(); ee.on('value', () => {});
ee.emit('value'); ee.emit('value', 1); console.log(ee.listeners('value'));`,
"listeners of the event 'value'",
],
[
"special error events",
`const ee = new EventEmitter(); ee.on('error', (value) => console.log(value)); ee.emit('error');`,
"emit('error') with 0 payload arguments",
],
[
"reserved stream events",
`const ee = new EventEmitter(); ee.on('data', (value) => console.log(value));
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/emitter-computed-name-listeners.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/emitter-listeners-dynamic-typed.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/emitter-listeners-dynamic.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/emitter-meta-listeners-dynamic.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/emitter-unhandled-error-payloads-typed.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/emitter-unhandled-error-payloads.cjs"
],
"diags": []
}
@@ -1,6 +0,0 @@
{
"order": [
"<repo>/tests/diagnostics/ee-computed-listeners.ts"
],
"diags": []
}
+56 -16
View File
@@ -1193,13 +1193,29 @@ bool scr_emitter_has_typed(ScrEmitter *em, ScrStr *name) {
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) {
/* Throws Node's ERR_UNHANDLED_ERROR for a non-Error payload: the message
* quotes `shown` (util.inspect of the payload) and `context` is the
* payload. */
SCR_COLD static void scr_ee_throw_unhandled(const char *shown, size_t len, const ScrDyn *er) {
ScrJsonBuf text;
scr_jb_init(&text);
scr_jb_puts(&text, "Unhandled error. (");
scr_jb_write(&text, shown, len);
scr_jb_putc(&text, ')');
ScrStr *message = scr_jb_finish(&text);
ScrError *err = scr_error_new(SCR_ERR_ERROR, message);
scr_str_release(message);
scr_error_set_code(err, "ERR_UNHANDLED_ERROR");
scr_error_set_prop(err, "context", 7, scr_dyn_retain((ScrDyn *)er));
scr_throw_obj(err, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg());
}
/* ERR_UNHANDLED_ERROR through an untyped value. 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);
bool shown = true;
switch (er->kind) {
case SCR_DYN_UNDEF: scr_jb_puts(&text, "undefined"); break;
@@ -1233,13 +1249,7 @@ static void scr_ee_unhandled_error(const ScrDyn *er) {
}
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);
scr_ee_throw_unhandled(message->data, message->len, er);
} else {
const char *refusal =
"emit('error') through an untyped value with this non-Error payload and no 'error' listener is not supported yet";
@@ -1251,16 +1261,35 @@ static void scr_ee_unhandled_error(const ScrDyn *er) {
/* 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) {
* arguments as given, and typed listeners the payload as an Error. `render`
* (util.inspect, when the call site links it) quotes the payload in the
* ERR_UNHANDLED_ERROR message; without it only primitive payloads render. */
static bool scr_ee_emit_error_dyn(ScrEmitter *em, ScrStr *name, ScrDyn *const *args, size_t argc,
ScrStr *(*render)(ScrDyn *, double, double)) {
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);
if (err) {
scr_dyn_throw(scr_dyn_retain((ScrDyn *)er));
} else if (render) {
ScrStr *shown = render((ScrDyn *)er, 0, 2);
if (scr_exc_pending()) {
/* A throwing inspect: Node quotes the payload's string conversion
* instead, whose own throw propagates. */
if (shown) scr_str_release(shown);
scr_exc_clear();
shown = scr_dyn_string_coerce_js(er);
}
if (shown) {
scr_ee_throw_unhandled(shown->data, shown->len, er);
scr_str_release(shown);
}
} else {
scr_ee_unhandled_error(er);
}
}
} else {
had = scr_ee_emit_mixed_va(em, name, args, argc, err != NULL, err);
@@ -1269,6 +1298,17 @@ static bool scr_ee_emit_error_dyn(ScrEmitter *em, ScrStr *name, ScrDyn *const *a
return had;
}
/* emit('error', ...args) from typed code whose payload is not statically
* an Error (the arguments boxed): scr_ee_emit_error_dyn, quoting the
* payload with `render`. */
bool scr_emitter_emit_error_value(ScrEmitter *em, ScrDyn *const *args, size_t argc,
ScrStr *(*render)(ScrDyn *, double, double)) {
ScrStr *name = scr_str_new("error", 5);
bool had = scr_ee_emit_error_dyn(em, name, args, argc, render);
scr_str_release(name);
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) {
@@ -1288,7 +1328,7 @@ bool scr_emitter_emit_message(ScrEmitter *em, ScrStr *name, const struct ScrMess
* 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, "error")) return scr_ee_emit_error_dyn(em, name, args, argc, NULL);
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);
@@ -109,6 +109,10 @@ ScrEmitter *scr_emitter_on_flex(ScrEmitter *em, ScrStr *name, const ScrDyn *cb,
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);
/* emit('error', ...args) with a non-Error payload from typed code, the
* arguments boxed; `render` is util.inspect's dyn entry (scr_insp_dyn). */
bool scr_emitter_emit_error_value(ScrEmitter *em, ScrDyn *const *args, size_t argc,
ScrStr *(*render)(ScrDyn *, double, double));
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
@@ -0,0 +1,14 @@
// listeners() and rawListeners() from TypeScript of an event registered
// under a computed name: the registered functions in order.
import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
const suffix = process.argv[2] ?? "x";
const onTopic = (): void => console.log("topic listener");
emitter.on(`topic:${suffix}`, onTopic);
emitter.on(`topic:${suffix}`, () => console.log("second"));
console.log(emitter.listeners("topic:x").length, emitter.listeners("topic:x")[0] === onTopic);
for (const l of emitter.listeners("topic:x")) l();
console.log(emitter.listeners("topic:y").length);
emitter.off("topic:x", onTopic);
console.log(emitter.rawListeners("topic:x").length);
@@ -0,0 +1,15 @@
// listeners() and rawListeners() from TypeScript of an event with a typed
// listener and a checked-dynamic one: each element is the registered
// function, called with the event's arguments.
import { EventEmitter } from "node:events";
const e = new EventEmitter();
const typed = (n: number, s: string): void => console.log("typed", n, s);
const loose = ((...args: unknown[]) => console.log("loose", args.length)) as any;
e.on("tick", typed);
e.on("tick", loose);
e.emit("tick", 1, "one");
console.log(e.listeners("tick").length, e.listeners("tick")[0] === typed, e.rawListeners("tick")[1] === loose);
for (const l of e.listeners("tick")) (l as (n: number, s: string) => void)(2, "two");
e.off("tick", typed);
e.emit("tick", 3, "three");
console.log(e.listenerCount("tick"), e.listeners("tick").length);
@@ -0,0 +1,32 @@
// listeners(), rawListeners() and getEventListeners() of events whose
// listeners include functions with untyped parameters or that read
// arguments: the array holds the registered functions in order.
const { EventEmitter, getEventListeners } = require('events');
const e = new EventEmitter();
function typed() { console.log('typed'); }
function packed() { console.log('packed', arguments.length); }
function withParam(a) { console.log('withParam', a); }
e.on('foo', typed);
e.on('foo', packed);
e.once('foo', withParam);
let fl = e.listeners('foo');
console.log(fl.length, fl[0] === typed, fl[1] === packed, fl[2] === withParam);
const raw = e.rawListeners('foo');
console.log(raw.length, raw[2] === withParam || typeof raw[2].listener === 'function');
console.log(getEventListeners(e, 'foo').length);
e.emit('foo', 1, 2);
fl = e.listeners('foo');
console.log(fl.length);
fl[0]();
fl[1](7, 8, 9);
e.off('foo', typed);
console.log(e.listeners('foo').length, e.listeners('foo')[0] === packed);
e.removeListener('foo', packed);
console.log(e.listeners('foo').length, e.listenerCount('foo'));
const g = new EventEmitter();
g.on('bar', function (x) { console.log('bar', x); });
g.on('bar', (x, y) => console.log('bar2', x, y));
g.emit('bar', 'a');
g.emit('bar', 'a', 'b', 'c');
console.log(g.listeners('bar').length, typeof g.listeners('bar')[1]);
console.log(e.listeners('nothing').length);
@@ -0,0 +1,37 @@
// Checked-dynamic 'newListener' and 'removeListener' listeners receive the
// event name and the added or removed listener function.
const EventEmitter = require('events');
function listener1() {}
function listener2(a) { console.log('l2', a); }
const ee = new EventEmitter();
ee.on('newListener', function (name, fn) {
console.log('new', name, fn === listener1, fn === listener2, typeof fn);
});
ee.on('hello', listener1);
ee.on('hello', listener2);
ee.on('removeListener', (name, cb) => {
console.log('removed', name, cb === listener1, cb === listener2);
});
ee.removeListener('hello', listener1);
ee.emit('hello', 5);
console.log(ee.listeners('hello').length, ee.listeners('hello')[0] === listener2);
ee.removeAllListeners('hello');
console.log(ee.listenerCount('hello'));
const other = new EventEmitter();
other.once('removeListener', (name, cb) => console.log('once-removed', name, typeof cb));
other.on('x', (n) => console.log('x', n));
other.emit('x', 1);
other.removeAllListeners();
console.log(other.eventNames());
// A meta listener produced by a wrapper factory, as test helpers do.
function wrap(fn) {
return function () {
return fn.apply(this, arguments);
};
}
function plain() {}
const wrapped = new EventEmitter();
wrapped.on('ping', plain);
wrapped.on('removeListener', wrap((name, cb) => console.log('wrapped removed', name, cb === plain)));
wrapped.removeListener('ping', plain);
@@ -0,0 +1,46 @@
// emit('error', payload) from typed code with a payload that is not an
// Error: Node's ERR_UNHANDLED_ERROR when nothing listens, the payload as
// given otherwise.
import { EventEmitter, errorMonitor } from "node:events";
interface UnhandledError extends Error {
code?: string;
context?: unknown;
}
const e = new EventEmitter();
const record = { id: 3, tags: ["a", "b"] };
const attempts: Array<() => boolean> = [
() => e.emit("error", "plain string"),
() => e.emit("error", 12.5),
() => e.emit("error", record),
() => e.emit("error", [1, 2, 3]),
() => e.emit("error", null),
() => e.emit("error"),
() => e.emit("error", "a", "b"),
];
for (const attempt of attempts) {
try {
attempt();
console.log("no throw");
} catch (caught) {
const err = caught as UnhandledError;
console.log(err.code, err.message, err.context === record, typeof err.context);
}
}
let monitored = 0;
e.on(errorMonitor, () => {
monitored++;
});
try {
e.emit("error", "watched");
} catch (caught) {
console.log((caught as UnhandledError).code, monitored);
}
const handled = new EventEmitter();
handled.on("error", (err: unknown) => console.log("got", err instanceof Error ? `Error: ${err.message}` : err));
console.log(handled.emit("error", 7));
console.log(handled.emit("error", { reason: "closed" }));
console.log(handled.emit("error", new Error("real")));
@@ -0,0 +1,67 @@
// emit('error', payload) with a payload that is not an Error: with no
// 'error' listener it throws ERR_UNHANDLED_ERROR, whose message quotes
// util.inspect of the payload and whose context is the payload; listeners
// receive the arguments as given.
const { EventEmitter, errorMonitor } = require("events");
const e = new EventEmitter();
const payloads = ["Accepts a string", "it's", { message: "Error!" }, 42, -0, null, undefined, true, [1, "two"], { nested: { deep: [1, 2] } }];
for (const v of payloads) {
try {
e.emit("error", v);
console.log("no throw");
} catch (err) {
console.log(err instanceof Error, err.name, err.code, err.message, err.context === v);
}
}
try {
e.emit("error");
} catch (err) {
console.log(err.code, err.message, err.context);
}
try {
e.emit("error", "first", "second");
} catch (err) {
console.log(err.code, err.message, err.context);
}
// errorMonitor sees the payload before the throw.
const m = new EventEmitter();
m.on(errorMonitor, function (v) {
console.log("monitor", v, arguments.length);
});
try {
m.emit("error", { code: 7 });
} catch (err) {
console.log("thrown", err.code, err.context.code);
}
// With an 'error' listener nothing throws.
const l = new EventEmitter();
l.on("error", function (a, b) {
console.log("listener", a, b, arguments.length);
});
console.log(l.emit("error", "x", 2));
console.log(l.emit("error", { k: 1 }));
console.log(l.emit("error"));
// An Error payload still throws itself.
try {
e.emit("error", new TypeError("typed"));
} catch (err) {
console.log(err.name, err.message, err.code);
}
// A payload whose util.inspect throws is quoted by its string conversion.
try {
e.emit("error", {
message: "Error!",
[require("util").inspect.custom]() {
throw new Error("no inspect");
},
});
} catch (err) {
console.log(err.code, err.message);
}
@@ -1,6 +0,0 @@
import { EventEmitter } from "node:events";
const emitter = new EventEmitter();
const suffix = process.argv[2] ?? "x";
emitter.on(`topic:${suffix}`, () => {});
emitter.listeners("topic:x");
@@ -1,8 +0,0 @@
ee-computed-listeners.ts:6:1 - error SC2020: 'listeners of the event 'topic:x'' is part of the standard library types but has no scriptc lowering yet
5 | emitter.on(`topic:${suffix}`, () => {});
6 | emitter.listeners("topic:x");
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~
7 |
hint: a listener of this event uses checked-dynamic dispatch, so the listener array has no one static element type — listenerCount(name) counts
@@ -0,0 +1 @@
{ "plain": 4200, "san": 7100 }
@@ -0,0 +1 @@
{ "plain": 4700, "san": 7300 }
@@ -0,0 +1 @@
{ "plain": 6300, "san": 8700 }
@@ -0,0 +1 @@
{ "plain": 4800, "san": 7700 }
@@ -0,0 +1 @@
{ "plain": 4900, "san": 8500 }
@@ -0,0 +1 @@
{ "plain": 5300, "san": 8800 }