Emit Node's Timeout delay warnings and convert untyped timer delays with ToNumber

setTimeout and setInterval set a delay outside [1, 2^31-1] to 1ms, as
before, and now also emit the warning Node's Timeout constructor emits
then: TimeoutOverflowWarning for each delay past 2^31-1, and
TimeoutNegativeWarning and TimeoutNaNWarning once per process. The
warnings reach the 'warning' listeners and the default stderr report on
the next tick, as process.emitWarning delivers them. An omitted or
undefined delay warns nothing, and neither do the global functions while
mock.timers mocks them; node:timers imports keep warning.

The check is the runtime's scr_timer_user_delay, which a call emits only
when its delay is not a literal in range. An untyped delay (a JavaScript
value) now converts with ToNumber, as Node's `after *= 1` does, instead of
failing a number check: null, booleans, strings, arrays and objects become
delays, and undefined stays the omitted delay.
This commit is contained in:
Chris Tate
2026-10-30 08:21:18 -05:00
parent f3a0858a5a
commit 253fed8940
15 changed files with 251 additions and 0 deletions
@@ -1856,6 +1856,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"timers.queueMicrotaskDyn": "scr_queue_microtask_dyn",
"timers.setImmediateFnValue": "scr_set_immediate_dyn_value",
"timers.immediatePromise": "scr_immediate_promise",
"timers.userDelay": "scr_timer_user_delay",
};
/** The mock-aware entry points (scr_test.c) that the timer calls of a
@@ -395,6 +395,15 @@ function lowerTimerCall(
);
// An omitted delay is NaN, Node's undefined: the loop clamps it to
// 1ms, and mock.timers queues it to run at the next tick.
let warned = false;
// An element read past the end is undefined in Node, which warns
// nothing; here it reads as NaN, so the runtime skips the NaN warning.
let delayNode = expr.arguments.nodes[1];
while (delayNode !== undefined && ts.isParenthesizedExpression(delayNode))
delayNode = delayNode.expression;
const delayKind =
(isRealTimerCallee(lowerer, expr.expression) ? 2 : member === "setInterval" ? 1 : 0) +
(delayNode !== undefined && ts.isElementAccessExpression(delayNode) ? 4 : 0);
let ms: IrExpr =
expr.arguments.nodes.length >= 2
? lowerer.lowerExpr(expr.arguments.nodes[1]!)
@@ -417,6 +426,45 @@ function lowerTimerCall(
// (`after *= 1`), so undefined is NaN, the omitted delay. Other
// unions stay as they are, and the lib boundary refuses them.
ms = lowerOptionalNumber(lowerer, ms, loc, expr.arguments.nodes[1]);
warned = warnOnDefinedDelay(ms, delayKind, loc);
} else if (ms.type.kind === "dyn") {
// An untyped delay converts with ToNumber; undefined stays the
// omitted delay (Node's `after === undefined` branch).
const tmp = lowerer.declareHiddenLocal("%timerDelay", DYN);
const stable: IrExpr = { kind: "varRef", localId: tmp.id, type: DYN, loc };
const converted: IrExpr = {
kind: "libCall",
fn: "dyn.numberConstructor",
args: [stable],
type: F64,
loc,
};
ms = {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: tmp.id, init: ms, loc }],
result: {
kind: "ternary",
cond: { kind: "dynTest", test: "undefined", value: stable, type: BOOL, loc },
then: { kind: "numLit", value: NaN, type: F64, loc },
else_: userDelay(converted, delayKind, loc),
type: F64,
loc,
},
type: F64,
loc,
};
warned = true;
}
// A delay Node sets to 1 emits its Timeout warning; a literal delay in
// range needs no check, and an omitted delay is undefined, which warns
// nothing.
if (
!warned &&
expr.arguments.nodes.length >= 2 &&
ms.type.kind === "f64" &&
!(ms.kind === "numLit" && ms.value >= 1 && ms.value <= 2147483647)
) {
ms = userDelay(ms, delayKind, loc);
}
if (member === "setInterval") {
return { kind: "libCall", fn: "timers.setInterval", args: [cb, ms], type: F64, loc };
@@ -532,6 +580,25 @@ function lowerTimerCall(
return null;
}
/** `timers.userDelay(ms, kind)`: the delay, after the warning Node's
* Timeout constructor emits when it sets the delay to 1. `kind` is 0 for
* the global setTimeout, 1 for the global setInterval (mock.timers mocks
* both) and 2 for a node:timers import (which it leaves alone), plus 4 for
* a delay read from an array element. */
function userDelay(ms: IrExpr, kind: number, loc: SrcLoc): IrExpr {
return { kind: "libCall", fn: "timers.userDelay", args: [ms, numLit(kind, loc)], type: F64, loc };
}
/** An optional delay (lowerOptionalNumber's undefined-or-number ternary):
* only the defined arm warns, since Node sets an undefined delay to 1
* without a warning. Answers whether the check was placed. */
function warnOnDefinedDelay(ms: IrExpr, kind: number, loc: SrcLoc): boolean {
const choice = ms.kind === "seqExpr" ? ms.result : ms;
if (choice.kind !== "ternary") return false;
choice.else_ = userDelay(choice.else_, kind, loc);
return true;
}
function hasBoolArm(lowerer: Lowerer, type: IrType): boolean {
return (
type.kind === "union" &&
@@ -459,6 +459,13 @@ function buildLibFnSignatures() {
"module.cacheHas": { argTypes: [STRING], result: BOOL },
"module.cacheKeys": { argTypes: [], result: arrayOf(STRING) },
"timers.setTimeout": { argTypes: [{ kind: "func", params: [], ret: VOID }, F64], result: VOID },
/** A setTimeout/setInterval delay that is not a literal in [1, 2^31-1]:
* the delay unchanged, after the warning Node's Timeout constructor
* emits when it sets the delay to 1 (scr_timer_user_delay). The second
* argument is 0 for the global setTimeout, 1 for the global
* setInterval and 2 for a node:timers import, plus 4 for a delay read
* from an array element. */
"timers.userDelay": { argTypes: [F64, F64], result: F64 },
/** The repeating timer pair. setInterval takes (callback, ms) like
* setTimeout and RETURNS the f64 handle the fallback declarations
* promise (ids start at 1, so truthiness narrowing works); the loop
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/timers-delay-warnings-mocked.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/timers-delay-warnings.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/timers-untyped-delays.cjs"
],
"diags": []
}
+67
View File
@@ -1242,6 +1242,73 @@ static double scr_timer_coerce_ms(double ms) {
return (double)(unsigned long long)ms; /* trunc: positive, finite, in range */
}
/* The warnings Node's Timeout constructor emits for a delay it sets to 1
* (lib/internal/timers.js): TimeoutOverflowWarning for one past
* TIMEOUT_MAX, and, once per process each, TimeoutNegativeWarning and
* TimeoutNaNWarning. process.emitWarning delivers them on the next tick:
* to the 'warning' listeners and the default report when the
* process-warning unit is linked, otherwise the default report alone. */
static SCR_TL bool scr_timer_warned_negative = false;
static SCR_TL bool scr_timer_warned_nan = false;
SCR_TL int scr_timer_mocked_apis = 0;
static void scr_timer_warn_tick(ScrClosure *self) {
ScrStr *name = (ScrStr *)scr_box_get_ref(self->caps[0]);
ScrStr *message = (ScrStr *)scr_box_get_ref(self->caps[1]);
if (scr_warning_emit_hook) {
char type[32];
snprintf(type, sizeof type, "%.*s", (int)name->len, name->data);
scr_warning_emit_hook(type, NULL, message);
} else {
fflush(stdout);
fprintf(stderr, "(node:%ld) %.*s: %.*s\n", (long)scr_process_pid(), (int)name->len, name->data,
(int)message->len, message->data);
if (!scr_warning_trace_hint_shown) {
scr_warning_trace_hint_shown = true;
fputs("(Use `node --trace-warnings ...` to show where the warning was created)\n", stderr);
}
}
scr_str_release(name);
scr_str_release(message);
}
double scr_timer_user_delay(double after, double kind) {
if (after >= 1 && after <= 2147483647.0) return after;
int k = (int)kind;
/* mock.timers replaces the global function, which then builds no
* Timeout. */
if ((k & 3) < 2 && (scr_timer_mocked_apis & ((k & 3) == 1 ? 2 : 1))) return after;
/* An element read past the end of a number array is undefined in Node,
* the default delay, and reads as NaN here. */
if (after != after && (k & 4)) return after;
const char *type = NULL, *tail = NULL;
if (after > 2147483647.0) {
type = "TimeoutOverflowWarning";
tail = " does not fit into a 32-bit signed integer.\nTimeout duration was set to 1.";
} else if (after < 0 && !scr_timer_warned_negative) {
scr_timer_warned_negative = true;
type = "TimeoutNegativeWarning";
tail = " is a negative number.\nTimeout duration was set to 1.";
} else if (after != after && !scr_timer_warned_nan) {
scr_timer_warned_nan = true;
type = "TimeoutNaNWarning";
tail = " is not a number.\nTimeout duration was set to 1.";
}
if (type == NULL) return after;
ScrStr *number = scr_num_to_string_radix_f64(after, 10);
ScrStr *suffix = scr_str_new(tail, strlen(tail));
ScrStr *message = scr_str_concat(number, suffix);
scr_str_release(number);
scr_str_release(suffix);
ScrClosure *tick = scr_closure_new((void *)&scr_timer_warn_tick, 2);
tick->caps[0] = scr_box_new(SCR_BOX_STR);
scr_box_set_ref(tick->caps[0], scr_str_new(type, strlen(type)));
tick->caps[1] = scr_box_new(SCR_BOX_STR);
scr_box_set_ref(tick->caps[1], message);
scr_next_tick(tick);
return after;
}
void scr_set_timeout(ScrClosure *cb, double ms) {
ms = scr_timer_coerce_ms(ms);
ScrTimer t = {scr_now_ms() + ms, scr_timer_seq++, cb /* ownership moves in */, 0, 0, true, ms};
+13
View File
@@ -454,6 +454,19 @@ ScrPromise *scr_immediate_promise(void);
void scr_clear_interval(double handle);
double scr_set_timeout_handle(ScrClosure *cb, double ms); /* cb ownership moves in */
/* A setTimeout/setInterval delay as given (after ToNumber): emits the
* warning Node's Timeout constructor emits when it sets the delay to 1
* (TimeoutOverflowWarning; TimeoutNegativeWarning and TimeoutNaNWarning
* once per process) on the next tick, and returns the delay unchanged.
* `kind` is 0 for the global setTimeout, 1 for the global setInterval,
* which emit nothing while mock.timers mocks them, and 2 for a node:timers
* import, which mock.timers leaves alone; bit 4 marks a delay read from an
* array element, whose NaN may be the undefined of a read past the end,
* which warns nothing. */
double scr_timer_user_delay(double after, double kind);
/* The mocked global timer functions (scr_test.c): bit 0 setTimeout, bit 1
* setInterval. */
extern SCR_TL int scr_timer_mocked_apis;
/* Queues `cb` (moved) on the MockTimers that mocks AbortSignal.timeout
* (scr_test.c), NULL while it is not mocked. AbortSignal.timeout then
* aborts its signal from that queue instead of arming a loop timer. */
+1
View File
@@ -2218,6 +2218,7 @@ static void scr_mt_hooks(void) {
scr_date_mock_now = date ? &date->now : NULL;
scr_mt_abort_timeout_arm = scr_mt_owner[SCR_MT_ABORT] ? &scr_mt_abort_arm : NULL;
scr_mt_wait_state = scr_mt_owner[SCR_MT_WAIT] ? 1 : scr_mt_wait_unbound ? 2 : 0;
scr_timer_mocked_apis = (scr_mt_owner[SCR_MT_TIMEOUT] ? 1 : 0) | (scr_mt_owner[SCR_MT_INTERVAL] ? 2 : 0);
}
/* Node's #toggleEnableTimers. Mocking Date while another MockTimers mocks
@@ -0,0 +1,22 @@
// @normalize-pid
// While mock.timers mocks setTimeout and setInterval, the mocked functions
// build no Timeout, so a negative or overflowing delay warns nothing;
// after reset the real functions warn again, and a node:timers import is
// never mocked.
import { mock } from "node:test";
import { setTimeout as realSetTimeout } from "node:timers";
process.on("warning", (warning) => console.log("warning", warning.name));
mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
setTimeout(() => console.log("mocked negative"), -5);
const interval = setInterval(() => {
console.log("mocked interval");
clearInterval(interval);
}, 2 ** 31);
realSetTimeout(() => console.log("imported overflow"), 2 ** 32);
mock.timers.tick(1);
mock.timers.reset();
setTimeout(() => console.log("real negative"), -5);
setTimeout(() => console.log("real overflow"), 2 ** 31);
+34
View File
@@ -0,0 +1,34 @@
// @normalize-pid
// The warnings Node's Timeout constructor emits when it sets a delay to
// 1ms: TimeoutOverflowWarning for each delay past 2^31-1, and
// TimeoutNegativeWarning and TimeoutNaNWarning once per process. They
// arrive on a later tick, so a 'warning' listener added after the timers
// sees them, after the default stderr report. An omitted or undefined
// delay, and delays in range, warn nothing.
const order: string[] = [];
function optional(ms?: number): number | undefined {
return ms;
}
setTimeout(() => order.push("overflow"), 2 ** 31);
setTimeout(() => order.push("negative"), -5);
setTimeout(() => order.push("negative again"), -0.5);
setTimeout(() => order.push("nan"), Number("x"));
setTimeout(() => order.push("nan again"), NaN);
setTimeout(() => order.push("omitted"));
setTimeout(() => order.push("optional undefined"), optional());
setTimeout(() => order.push("optional overflow"), optional(2 ** 32));
setTimeout(() => order.push("in range"), 1);
setTimeout(() => order.push("zero"), 0);
const interval = setInterval(() => {
order.push("interval");
clearInterval(interval);
}, 1e12);
process.on("warning", (warning) => {
console.log("warning", warning.name, JSON.stringify(warning.message));
});
console.log("registered");
setTimeout(() => console.log(order.join(", ")), 20);
+18
View File
@@ -0,0 +1,18 @@
// @normalize-pid
'use strict';
// setTimeout and setInterval convert an untyped delay with ToNumber, as
// Node's `after *= 1` does: undefined, null, booleans, strings, arrays and
// objects all become a 1ms delay, with Node's warnings for the NaN,
// negative and overflowing ones (test-timers.js).
const inputs = [undefined, null, true, false, '', '7', [], [3], {}, NaN, Infinity, -Infinity, -0, 0.5, 2147483648];
const fired = [];
inputs.forEach((value, index) => {
setTimeout(() => fired.push(`t${index}`), value);
const handle = setInterval(() => {
clearInterval(handle);
fired.push(`i${index}`);
}, value);
});
setTimeout(() => {
console.log(fired.length, fired.filter((name) => name.startsWith('t')).join(' '));
}, 30);
@@ -0,0 +1 @@
{ "plain": 3121, "san": 3127 }
@@ -0,0 +1 @@
{ "plain": 3168, "san": 5267 }
@@ -0,0 +1 @@
{ "plain": 3196, "san": 5532 }