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 }