The max-listeners argument ladders answer Node's exact errors

- Instance setMaxListeners over a non-number routes through the checked-dynamic ladder (emitter.setMaxChk): ERR_INVALID_ARG_TYPE with the rendered Received tail, the existing ERR_OUT_OF_RANGE range gate for numbers, and a well-typed dyn still applies.
- events.defaultMaxListeners is a real module property now: reads answer the process-wide default, writes (statement and expression position) validate through emitter.setDefaultMaxChk with Node's own message slot ("defaultMaxListeners"); the qualified static EventEmitter.setMaxListeners(n) shares the ladder under its "setMaxListeners" slot, and the per-target static form with a provably-non-emitter target throws ERR_INVALID_ARG_TYPE on "eventTargets".
- error.argTypeThrow joins error.nodeThrow as the generic always-throwing lowered arm whose Received tail renders at runtime.
- Corpus 2560 pins the ladders byte-identically on both backends.
This commit is contained in:
Chris Tate
2026-07-23 22:55:52 -05:00
parent a6f43649c5
commit b06540dce6
12 changed files with 266 additions and 2 deletions
@@ -4294,6 +4294,12 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp {
return finish(`scr_buffer_new_string_fail(${arg(0)})`);
case "fs.toUnixTimestamp":
return finish(`scr_fs_to_unix_timestamp(${arg(0)})`);
case "error.argTypeThrow":
// Always throws with the runtime-rendered Received tail (the
// error.nodeThrow dummy pattern). Borrows all three.
return finish(
`(scr_throw_arg_type(${arg(0)}, ${arg(1)}, ${arg(2)}), ${isRefCounted(e.type) ? `(${cType(e.type).trim()})NULL` : "0"})`,
);
// The fs Buffer forms (scr_bytes_io.c): the sync pair throws
// like the utf8 forms (may-throw seed set); the promise form
// rejects instead.
@@ -4860,10 +4866,17 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp {
return finish(
`(${cType(e.type).trim()})scr_emitter_set_max((ScrEmitter *)${arg(0)}, ${arg(1)})`,
);
case "emitter.setMaxChk":
// The checked-dynamic ladder (may throw); +1 receiver back.
return finish(
`(${cType(e.type).trim()})scr_emitter_set_max_chk((ScrEmitter *)${arg(0)}, ${arg(1)})`,
);
case "emitter.getMax":
return finish(`scr_emitter_get_max((ScrEmitter *)${arg(0)})`);
case "emitter.setDefaultMax":
return finish(`scr_emitter_set_default_max(${arg(0)})`);
case "emitter.setDefaultMaxChk":
return finish(`scr_emitter_set_default_max_chk(${arg(0)}, ${arg(1)})`);
case "emitter.getDefaultMax":
return finish(`scr_emitter_get_default_max()`);
// node:stream (scr_stream.c — linked exactly when these appear,
@@ -509,6 +509,8 @@ const LIB_FN_SYMS: Record<string, string> = {
"emitter.names": "scr_emitter_event_names",
"emitter.listeners": "scr_emitter_listeners",
"emitter.setMax": "scr_emitter_set_max",
"emitter.setMaxChk": "scr_emitter_set_max_chk",
"emitter.setDefaultMaxChk": "scr_emitter_set_default_max_chk",
"emitter.getMax": "scr_emitter_get_max",
"emitter.setDefaultMax": "scr_emitter_set_default_max",
"emitter.getDefaultMax": "scr_emitter_get_default_max",
@@ -9289,6 +9291,24 @@ class LlEmitter {
// Loop liveness first (one table for generic and special shapes).
if (USES_TIMERS_LIB_FNS.has(e.fn)) this.usesTimers = true;
// The handful with non-generic shapes first.
if (e.fn === "error.argTypeThrow") {
// Always throws with the runtime-rendered Received tail (the
// error.nodeThrow dummy pattern). Borrows all three.
const an = this.emitExpr(e.args[0]!);
const ex = this.emitExpr(e.args[1]!);
const got = this.emitExpr(e.args[2]!);
this.declare(`declare void @scr_throw_arg_type(ptr, ptr, ptr)`);
B.line(`call void @scr_throw_arg_type(ptr ${an.name}, ptr ${ex.name}, ptr ${got.name})`);
const ty = this.llType(e.type);
if (ty === "void") {
this.emitPendingCheck();
return { name: "", type: e.type };
}
const dummy = ty === "double" ? f64Lit(0) : ty === "i1" ? "false" : "null";
const out = this.own({ name: dummy, type: e.type });
this.emitPendingCheck();
return out;
}
if (e.fn === "error.nodeThrow") {
// The compiler-resolved Node-parity throw (always throws — the
// typed dummy is abandoned by the pending check's unwind).
@@ -507,6 +507,21 @@ export function lowerEmitterMethodCall(L: Lowerer, call: ts.CallExpression,
// everything else fences by its static name.
if (L.checker.getConstructSignatures(L.typeOf(access.expression)).length > 0) {
if (member === "setMaxListeners" && args.length === 1) {
if (L.mapTypeOf(L.typeOf(args[0]!))?.kind !== "f64") {
// Node's runtime ladder over the DOM value ("setMaxListeners" is
// the message's slot for the static form).
const raw = L.lowerExpr(args[0]!);
if (raw.type.kind === "dyn" || raw.kind === "unitLit" || L.dynConvertible(raw.type)) {
const n: IrExpr = raw.type.kind === "dyn" ? raw : { kind: "dynFrom", value: raw, type: DYN, loc };
return {
kind: "libCall",
fn: "emitter.setDefaultMaxChk",
args: [n, { kind: "strLit", value: "setMaxListeners", type: STRING, loc }],
type: VOID,
loc,
};
}
}
const n = L.lowerExprExpecting(args[0]!, F64);
if (n.type.kind !== "f64") {
L.noLowering(
@@ -518,6 +533,36 @@ export function lowerEmitterMethodCall(L: Lowerer, call: ts.CallExpression,
return { kind: "libCall", fn: "emitter.setDefaultMax", args: [n], type: VOID, loc };
}
if (member === "setMaxListeners") {
// The per-target form with a target that is provably NOT an
// emitter/EventTarget (the invalid-input probes): Node validates n
// first, then throws ERR_INVALID_ARG_TYPE on the target. Claimed
// when n is a pure read (identifier/literal — nothing to evaluate)
// and the target crosses into the DOM for the Received tail.
if (args.length === 2 && !args.some(ts.isSpreadElement)) {
const nT = L.mapTypeOf(L.typeOf(args[0]!));
const tT = L.mapTypeOf(L.typeOf(args[1]!));
const nPure = ts.isIdentifier(args[0]!) || ts.isLiteralExpression(args[0]!);
const targetNotEmitter =
tT !== null && tT.kind !== "dyn" && tT.kind !== "jsval" &&
!(tT.kind === "object");
if (nT?.kind === "f64" && nPure && targetNotEmitter) {
const raw = L.lowerExpr(args[1]!);
if (raw.type.kind === "dyn" || raw.kind === "unitLit" || L.dynConvertible(raw.type)) {
const got: IrExpr = raw.type.kind === "dyn" ? raw : { kind: "dynFrom", value: raw, type: DYN, loc };
return {
kind: "libCall",
fn: "error.argTypeThrow",
args: [
{ kind: "strLit", value: "eventTargets", type: STRING, loc },
{ kind: "strLit", value: "an instance of EventEmitter or EventTarget", type: STRING, loc },
got,
],
type: VOID,
loc,
};
}
}
}
L.noLowering(
`EventEmitter.setMaxListeners with ${args.length} arguments`,
call,
@@ -774,6 +819,16 @@ export function lowerEmitterMethodCall(L: Lowerer, call: ts.CallExpression,
if (member === "setMaxListeners") {
if (args.length !== 1) L.noLowering(`setMaxListeners with ${args.length} arguments`, call);
const receiver = L.lowerExpr(access.expression);
if (L.mapTypeOf(L.typeOf(args[0]!))?.kind !== "f64") {
// The invalid-input probes (string n, dyn helpers): Node's ladder
// runs at runtime — ERR_INVALID_ARG_TYPE for non-numbers,
// ERR_OUT_OF_RANGE below zero, and a well-typed dyn still applies.
const raw = L.lowerExpr(args[0]!);
if (raw.type.kind === "dyn" || raw.kind === "unitLit" || L.dynConvertible(raw.type)) {
const n: IrExpr = raw.type.kind === "dyn" ? raw : { kind: "dynFrom", value: raw, type: DYN, loc };
return { kind: "libCall", fn: "emitter.setMaxChk", args: [receiver, n], type: receiver.type, loc };
}
}
const n = L.lowerExprExpecting(args[0]!, F64);
return { kind: "libCall", fn: "emitter.setMax", args: [receiver, n], type: receiver.type, loc };
}
@@ -6618,6 +6618,49 @@ export function lowerBinary(L: Lowerer, expr: ts.BinaryExpression): IrExpr {
const value = L.lowerExprExpecting(expr.right, target.type);
return { kind: "assignExpr", localId: target.id, value, type: target.type, loc };
}
// `events.defaultMaxListeners = v` — the module-property write
// Node validates (validateNumber(n, 'defaultMaxListeners', 0)):
// the value crosses into the DOM and the runtime ladder throws
// ERR_INVALID_ARG_TYPE / ERR_OUT_OF_RANGE with Node's exact slot
// name; valid numbers apply. The expression's value is the RHS
// (JS's assignment yield).
if (
op === ts.SyntaxKind.EqualsToken &&
ts.isPropertyAccessExpression(expr.left) &&
!expr.left.questionDotToken &&
expr.left.name.text === "defaultMaxListeners"
) {
const bi = L.builtinMemberOf(expr.left);
if (bi && bi.module === "events" && bi.member === "defaultMaxListeners") {
const rhs = L.lowerExpr(expr.right);
if (rhs.type.kind === "dyn" || rhs.kind === "unitLit" || L.dynConvertible(rhs.type)) {
const valTmp = L.declareHiddenLocal("%setVal", rhs.type);
const valRef = (): IrExpr => ({ kind: "varRef", localId: valTmp.id, type: rhs.type, loc });
const dynVal: IrExpr =
rhs.type.kind === "dyn" ? valRef() : { kind: "dynFrom", value: valRef(), type: DYN, loc };
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: valTmp.id, init: rhs, loc },
{
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "emitter.setDefaultMaxChk",
args: [dynVal, { kind: "strLit", value: "defaultMaxListeners", type: STRING, loc }],
type: VOID,
loc,
},
loc,
},
],
result: valRef(),
type: rhs.type,
loc,
};
}
}
}
// Member targets whose storage IS a module global — namespace
// members (`N.x`) and expando function members (`Foo.baz`): the
// same assignExpr, since the "member" is a variable.
@@ -3578,6 +3578,32 @@ function isEsModuleStamp(expr: ts.Expression): boolean {
};
}
}
// `events.defaultMaxListeners = v` in STATEMENT position — the
// module-property write Node validates (the lowerBinary
// expression twin): the value crosses into the DOM, the runtime
// ladder throws Node's exact errors, valid numbers apply.
if (!expr.left.questionDotToken && expr.left.name.text === "defaultMaxListeners") {
const bi = L.builtinMemberOf(expr.left);
if (bi && bi.module === "events" && bi.member === "defaultMaxListeners") {
const loc = locOf(expr);
const rhs = L.lowerExpr(expr.right);
if (rhs.type.kind === "dyn" || rhs.kind === "unitLit" || L.dynConvertible(rhs.type)) {
const dynVal: IrExpr =
rhs.type.kind === "dyn" ? rhs : { kind: "dynFrom", value: rhs, type: DYN, loc };
return {
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "emitter.setDefaultMaxChk",
args: [dynVal, { kind: "strLit", value: "defaultMaxListeners", type: STRING, loc }],
type: VOID,
loc,
},
loc,
};
}
}
}
// `r._read = fn` / `w._write = fn` (and the other underscore
// methods) on a runtime-stream-rooted receiver: Node's
// own-property shadow of the prototype method — the runtime
@@ -7083,6 +7083,11 @@ export class Lowerer {
}
const bi = this.builtinMemberOf(expr);
if (!bi) return null;
// events.defaultMaxListeners READS the process-wide default (its
// write twin routes through emitter.setDefaultMaxChk).
if (bi.module === "events" && bi.member === "defaultMaxListeners") {
return { kind: "libCall", fn: "emitter.getDefaultMax", args: [], type: F64, loc };
}
const c = builtinModuleConstOf(this, bi.module, bi.member);
if (c !== undefined) return builtinConstLit(c, loc);
if (bi.member === "constants" && bi.module === "fs") {
+16
View File
@@ -2521,6 +2521,19 @@ export type IrLibFn =
* finite numbers coerce (negatives answer now/1000, Node's shape);
* everything else throws Node's ERR_INVALID_ARG_TYPE. May-throw. */
| "fs.toUnixTimestamp"
/** The compiler-resolved ERR_INVALID_ARG_TYPE throw with a RUNTIME-
* rendered Received tail: args [argname, "of type ..." clause, the
* offending DOM value]. ALWAYS THROWS; polymorphic result (the
* error.nodeThrow pattern). May-throw seed. */
| "error.argTypeThrow"
/** The checked-dynamic max-listeners ladders: setMaxChk is the
* instance form over a DOM n (non-numbers ERR_INVALID_ARG_TYPE,
* negatives/NaN ERR_OUT_OF_RANGE; +1 receiver back — chaining);
* setDefaultMaxChk is the static/property form, its second argument
* naming the message slot ("setMaxListeners" for the static call,
* "defaultMaxListeners" for the module-property assignment). */
| "emitter.setMaxChk"
| "emitter.setDefaultMaxChk"
/** The Buffer forms of the fs quartet: readFileSync(path) with NO
* encoding → bytes<u8> (+1), writeFileSync(path, bytes), and the
* fs/promises readFile(path) no-encoding form (an already-settled
@@ -6204,6 +6217,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"bytes.compareChk",
"buffer.newStringFail",
"fs.toUnixTimestamp",
"error.argTypeThrow",
"emitter.setMaxChk",
"emitter.setDefaultMaxChk",
"fs.readFileSyncBytes",
"fs.writeFileSyncBytes",
"zlib.inflateSync",
+7 -2
View File
@@ -672,6 +672,9 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
"bytes.compareChk": { argTypes: [BYTES_U8, DYN, DYN, DYN, DYN, DYN], result: F64 },
"buffer.newStringFail": { argTypes: [DYN], result: BYTES_U8 },
"fs.toUnixTimestamp": { argTypes: [DYN], result: F64 },
"error.argTypeThrow": { argTypes: [STRING, STRING, DYN], result: VOID },
"emitter.setMaxChk": { argTypes: [null, DYN], result: VOID },
"emitter.setDefaultMaxChk": { argTypes: [DYN, STRING], result: VOID },
"fs.readFileSyncBytes": { argTypes: [STRING], result: BYTES_U8 },
"fs.writeFileSyncBytes": { argTypes: [STRING, BYTES_U8], result: VOID },
"fsp.readFileBytes": { argTypes: [STRING], result: { kind: "promise", inner: BYTES_U8 } },
@@ -3831,7 +3834,7 @@ function validateFunction(
// of the undefined global mapped to (never materialized).
break;
}
if (e.fn === "error.nodeThrow") {
if (e.fn === "error.nodeThrow" || e.fn === "error.argTypeThrow") {
// Always throws — the result type is the replaced expression's
// own (never materialized; the global.undefRead pattern).
break;
@@ -4050,6 +4053,7 @@ function validateFunction(
break;
}
if (e.fn.startsWith("emitter.") && e.fn !== "emitter.setDefaultMax" &&
e.fn !== "emitter.setDefaultMaxChk" &&
e.fn !== "emitter.getDefaultMax" && e.fn !== "emitter.checkListener") {
// Receiver: an emitter-hierarchy object (the %EventEmitter class
// itself, or a class whose base chain reaches it). emitter.new
@@ -4077,7 +4081,8 @@ function validateFunction(
if (e.fn === "emitter.on" || e.fn === "emitter.off" ||
e.fn === "emitter.onDyn" || e.fn === "emitter.offDyn" ||
e.fn === "emitter.onData" || e.fn === "emitter.onDataDyn" ||
e.fn === "emitter.removeAll" || e.fn === "emitter.setMax") {
e.fn === "emitter.removeAll" || e.fn === "emitter.setMax" ||
e.fn === "emitter.setMaxChk") {
if (!typeEquals(e.type, e.args[0]!.type)) {
err(`libCall ${e.fn} must return its receiver's type (the chaining 'this')`, e.loc);
}
+34
View File
@@ -662,6 +662,18 @@ ScrArr *scr_emitter_listeners(ScrEmitter *em, ScrStr *name) {
/* ── max listeners ────────────────────────────────────────────────────── */
/* The checked-dynamic setMaxListeners ladder: non-numbers throw Node's
* ERR_INVALID_ARG_TYPE ("The \"setMaxListeners\" argument must be of
* type number. Received ..."), numbers run the range gate below.
* Borrowed dyn; +1 receiver either way (the pending check unwinds). */
ScrEmitter *scr_emitter_set_max_chk(ScrEmitter *em, const ScrDyn *n) {
if (n->kind != SCR_DYN_NUM) {
scr_dyn_arg_type_fail("setMaxListeners", "of type number", n);
return scr_emitter_retain(em);
}
return scr_emitter_set_max(em, n->v.num);
}
ScrEmitter *scr_emitter_set_max(ScrEmitter *em, double n) {
if (!(n >= 0)) { /* negatives and NaN — Node's validateNumber */
char num[32];
@@ -682,6 +694,28 @@ double scr_emitter_get_max(ScrEmitter *em) {
return scr_emitter_default_max;
}
/* The checked-dynamic defaultMaxListeners ladder — `name` picks the
* message's slot ("setMaxListeners" for the static call,
* "defaultMaxListeners" for the module-property assignment, Node's own
* split). Borrowed. */
void scr_emitter_set_default_max_chk(const ScrDyn *n, const ScrStr *name) {
if (n->kind != SCR_DYN_NUM) {
scr_dyn_arg_type_fail(name->data, "of type number", n);
return;
}
if (!(n->v.num >= 0)) {
char num[32];
size_t nlen = scr_f64_to_str(n->v.num, num);
char msg[128];
int len = snprintf(msg, sizeof msg,
"The value of \"%s\" is out of range. It must be >= 0. Received %.*s",
name->data, (int)nlen, num);
scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE");
return;
}
scr_emitter_set_default_max(n->v.num);
}
void scr_emitter_set_default_max(double n) {
if (!(n >= 0)) { /* negatives and NaN — Node's validateNumber(n, "setMaxListeners", 0) */
char num[32];
+8
View File
@@ -635,6 +635,14 @@ const char *scr_dyn_specific_type(const ScrDyn *cb, char *detail, size_t cap) {
* number (5)") — the handle dispatchers' and argument validators'
* per-arg gates. `expected` is the full "of type ..."/"an instance of
* ..." clause. */
/* The compiler-resolved ERR_INVALID_ARG_TYPE throw with a RUNTIME-
* rendered Received tail (error.argTypeThrow — the always-throwing
* lowered arms whose offending value is not a literal). Borrows all
* three; always throws catchably. */
void scr_throw_arg_type(const ScrStr *argname, const ScrStr *expected, const ScrDyn *got) {
scr_dyn_arg_type_fail(argname->data, expected->data, got);
}
void scr_dyn_arg_type_fail(const char *argname, const char *expected, const ScrDyn *got) {
char detail[64];
const char *d = scr_dyn_specific_type(got, detail, sizeof detail);
+6
View File
@@ -2968,6 +2968,9 @@ void scr_dyn_check_listener(const ScrDyn *cb, const char *argname);
* generic ERR_INVALID_ARG_TYPE thrower over it (`expected` is the whole
* "of type ..." clause). The handle dispatchers' per-arg gates. */
const char *scr_dyn_specific_type(const ScrDyn *v, char *buf, size_t cap);
/* ERR_INVALID_ARG_TYPE with the runtime-rendered Received tail (the
* error.argTypeThrow libCall). Borrows all three; always throws. */
void scr_throw_arg_type(const ScrStr *argname, const ScrStr *expected, const ScrDyn *got);
void scr_dyn_arg_type_fail(const char *argname, const char *expected, const ScrDyn *got);
/* Listener-closure builders for the handle dispatchers' .on(...) paths:
* a runtime-built ScrClosure whose capture is the boxed dyn listener and
@@ -4181,6 +4184,9 @@ ScrBytes *scr_buffer_new_string_fail(const ScrDyn *got);
* numbers coerce (negatives answer now/1000), the rest throw Node's
* ERR_INVALID_ARG_TYPE. Borrowed. */
double scr_fs_to_unix_timestamp(const ScrDyn *t);
/* The checked-dynamic max-listeners ladders (scr_events_emitter.c). */
ScrEmitter *scr_emitter_set_max_chk(ScrEmitter *em, const ScrDyn *n);
void scr_emitter_set_default_max_chk(const ScrDyn *n, const ScrStr *name);
double scr_bytes_index_of(const ScrBytes *b, const ScrBytes *needle, double off, double align, bool fwd);
double scr_bytes_index_of_num(const ScrBytes *b, double v, double off, bool fwd);
ScrBytes *scr_bytes_fill(ScrBytes *b, const ScrBytes *pattern, double nargs, double offset, double end);
@@ -0,0 +1,33 @@
// The max-listeners argument ladders, Node-exact: setMaxListeners over a
// non-number throws ERR_INVALID_ARG_TYPE with the rendered Received
// tail, negatives/NaN throw validateNumber's ERR_OUT_OF_RANGE, the
// module-property assignment names its own slot (defaultMaxListeners),
// the qualified static names setMaxListeners, and valid values apply.
'use strict';
const events = require('events');
const e = new events.EventEmitter();
const show = (fn) => {
try {
console.log('ret', fn());
} catch (err) {
console.log(`${err.name}|${err.code}|${err.message}`);
}
};
show(() => { e.setMaxListeners(NaN); });
show(() => { e.setMaxListeners(-1); });
show(() => { e.setMaxListeners('and even this'); });
show(() => { e.setMaxListeners(null); });
show(() => { e.setMaxListeners(42); });
console.log(e.getMaxListeners());
show(() => { events.defaultMaxListeners = NaN; });
show(() => { events.defaultMaxListeners = -1; });
show(() => { events.defaultMaxListeners = 'and even this'; });
show(() => { events.defaultMaxListeners = 20; });
console.log(events.defaultMaxListeners);
show(() => { events.EventEmitter.setMaxListeners(NaN); });
show(() => { events.EventEmitter.setMaxListeners('nope'); });
show(() => { events.EventEmitter.setMaxListeners(11); });
console.log(events.defaultMaxListeners);