From 253fed8940996d8020c0bf6b35a6002b141dd6b3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 08:21:18 -0500 Subject: [PATCH] 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. --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/lower-timers.ts | 67 +++++++++++++++++++ .../compiler/src/ir/builtin-signatures.ts | 7 ++ .../timers-delay-warnings-mocked.ts.json | 6 ++ .../corpus/timers-delay-warnings.ts.json | 6 ++ .../corpus/timers-untyped-delays.cjs.json | 6 ++ packages/runtime/src/scr_async.c | 67 +++++++++++++++++++ packages/runtime/src/scr_async.h | 13 ++++ packages/runtime/src/scr_test.c | 1 + tests/corpus/timers-delay-warnings-mocked.ts | 22 ++++++ tests/corpus/timers-delay-warnings.ts | 34 ++++++++++ tests/corpus/timers-untyped-delays.cjs | 18 +++++ .../timers-delay-warnings-mocked.ts.json | 1 + .../timers-delay-warnings.ts.json | 1 + .../timers-untyped-delays.cjs.json | 1 + 15 files changed, 251 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-delay-warnings-mocked.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-delay-warnings.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-untyped-delays.cjs.json create mode 100644 tests/corpus/timers-delay-warnings-mocked.ts create mode 100644 tests/corpus/timers-delay-warnings.ts create mode 100644 tests/corpus/timers-untyped-delays.cjs create mode 100644 tests/harness/corpus-costs/timers-delay-warnings-mocked.ts.json create mode 100644 tests/harness/corpus-costs/timers-delay-warnings.ts.json create mode 100644 tests/harness/corpus-costs/timers-untyped-delays.cjs.json diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 5be7eb0b36..60d1049d85 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1856,6 +1856,7 @@ export const LIB_FN_SYMS: Record = { "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 diff --git a/packages/compiler/src/frontend/lowering/lower-timers.ts b/packages/compiler/src/frontend/lowering/lower-timers.ts index c76b2e667f..a5d2ca90c0 100644 --- a/packages/compiler/src/frontend/lowering/lower-timers.ts +++ b/packages/compiler/src/frontend/lowering/lower-timers.ts @@ -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" && diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 9a7d304ee2..9f41e89175 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -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 diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-delay-warnings-mocked.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-delay-warnings-mocked.ts.json new file mode 100644 index 0000000000..b4dfaf4e85 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-delay-warnings-mocked.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/timers-delay-warnings-mocked.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-delay-warnings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-delay-warnings.ts.json new file mode 100644 index 0000000000..75f45b0288 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-delay-warnings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/timers-delay-warnings.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-untyped-delays.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-untyped-delays.cjs.json new file mode 100644 index 0000000000..4ad0bdfcd6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timers-untyped-delays.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/timers-untyped-delays.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 5647a596fd..219f7892b2 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -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}; diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index 80fa3739a0..f39d624a5c 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -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. */ diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index 762e25c615..029ddbb061 100644 --- a/packages/runtime/src/scr_test.c +++ b/packages/runtime/src/scr_test.c @@ -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 diff --git a/tests/corpus/timers-delay-warnings-mocked.ts b/tests/corpus/timers-delay-warnings-mocked.ts new file mode 100644 index 0000000000..75aba9b201 --- /dev/null +++ b/tests/corpus/timers-delay-warnings-mocked.ts @@ -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); diff --git a/tests/corpus/timers-delay-warnings.ts b/tests/corpus/timers-delay-warnings.ts new file mode 100644 index 0000000000..f506220397 --- /dev/null +++ b/tests/corpus/timers-delay-warnings.ts @@ -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); diff --git a/tests/corpus/timers-untyped-delays.cjs b/tests/corpus/timers-untyped-delays.cjs new file mode 100644 index 0000000000..d79eaa5102 --- /dev/null +++ b/tests/corpus/timers-untyped-delays.cjs @@ -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); diff --git a/tests/harness/corpus-costs/timers-delay-warnings-mocked.ts.json b/tests/harness/corpus-costs/timers-delay-warnings-mocked.ts.json new file mode 100644 index 0000000000..f902af7644 --- /dev/null +++ b/tests/harness/corpus-costs/timers-delay-warnings-mocked.ts.json @@ -0,0 +1 @@ +{ "plain": 3121, "san": 3127 } diff --git a/tests/harness/corpus-costs/timers-delay-warnings.ts.json b/tests/harness/corpus-costs/timers-delay-warnings.ts.json new file mode 100644 index 0000000000..31d006c44e --- /dev/null +++ b/tests/harness/corpus-costs/timers-delay-warnings.ts.json @@ -0,0 +1 @@ +{ "plain": 3168, "san": 5267 } diff --git a/tests/harness/corpus-costs/timers-untyped-delays.cjs.json b/tests/harness/corpus-costs/timers-untyped-delays.cjs.json new file mode 100644 index 0000000000..623cd8b8bd --- /dev/null +++ b/tests/harness/corpus-costs/timers-untyped-delays.cjs.json @@ -0,0 +1 @@ +{ "plain": 3196, "san": 5532 }