Schedule a node:timers import with an untyped delay as a real timer

An untyped delay converts before the timer call and waits in a hidden
local, so the lowered call sits inside a seqExpr. The realTimer mark was
applied to the value lowerTimerCall returned, which that wrapper hid, so
an imported setTimeout or setInterval with an untyped delay emitted the
mock-aware entry point and its callback ran on the mocked queue.

timers-delay-warnings-mocked.ts schedules an imported setTimeout with an
untyped delay while mock.timers is enabled and calls the callback once the
script ends, after the tick; timer-delay-lowering.test.ts pins the
realTimer mark on the call inside the seqExpr.
This commit is contained in:
Chris Tate
2026-10-30 11:37:49 -05:00
parent 84b3d1f475
commit fab8af0d5c
3 changed files with 47 additions and 9 deletions
@@ -341,14 +341,18 @@ function isRealTimerCallee(lowerer: Lowerer, callee: ts.Expression): boolean {
}
/** Marks a lowered timer call `realTimer` when its callee is a binding
* mock.timers does not replace. */
* mock.timers does not replace. A call whose delay is stored first returns a
* `seqExpr` around the call, so the mark goes on the call inside it. */
function markRealTimerCall(
lowerer: Lowerer,
expr: ts.CallExpression,
lowered: IrExpr | null,
): IrExpr | null {
if (lowered?.kind !== "libCall" || !MOCKABLE_TIMER_FNS.has(lowered.fn)) return lowered;
return isRealTimerCallee(lowerer, expr.expression) ? { ...lowered, realTimer: true } : lowered;
const call = lowered?.kind === "seqExpr" ? lowered.result : lowered;
if (call?.kind !== "libCall" || !MOCKABLE_TIMER_FNS.has(call.fn)) return lowered;
if (!isRealTimerCallee(lowerer, expr.expression)) return lowered;
const marked: IrExpr = { ...call, realTimer: true };
return lowered?.kind === "seqExpr" ? { ...lowered, result: marked } : marked;
}
/** One timer call by MEMBER NAME — the shared lowering behind the ambient
@@ -5,8 +5,10 @@ import { expect, test } from "vitest";
import { compile, deserializeModule } from "../src/index.js";
import type { IrExpr } from "../src/ir/ir.js";
/** The delay argument of every setTimeout/setInterval libCall in the module. */
async function timerDelays(source: string): Promise<IrExpr[]> {
type TimerCall = Extract<IrExpr, { kind: "libCall" }>;
/** Every setTimeout/setInterval libCall in the module. */
async function timerCalls(source: string): Promise<TimerCall[]> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-timer-delays-"));
try {
const entry = join(dir, "main.ts");
@@ -16,7 +18,7 @@ async function timerDelays(source: string): Promise<IrExpr[]> {
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const mod = deserializeModule(await readFile(outPath, "utf8"));
const delays: IrExpr[] = [];
const calls: TimerCall[] = [];
const visit = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) visit(item);
@@ -29,16 +31,21 @@ async function timerDelays(source: string): Promise<IrExpr[]> {
typeof node.fn === "string" &&
/^timers\.set(Timeout|TimeoutHandle|Interval)$/.test(node.fn)
)
delays.push(node.args![1]!);
calls.push(node as TimerCall);
for (const child of Object.values(value)) visit(child);
};
visit(mod.functions);
return delays;
return calls;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** The delay argument of every setTimeout/setInterval libCall in the module. */
async function timerDelays(source: string): Promise<IrExpr[]> {
return (await timerCalls(source)).map((call) => call.args[1]!);
}
test("number delays pass to the timer unchanged", async () => {
const delays = await timerDelays(
`function arm(ms: number): void {
@@ -94,3 +101,23 @@ test("a number | undefined delay converts with ToNumber", async () => {
});
}
});
test("an imported timer call stays real through an untyped delay", async () => {
const calls = await timerCalls(
`import { setTimeout as realSetTimeout, setInterval as realSetInterval } from "node:timers";
function arm(delay: any): void {
realSetTimeout(() => console.log("a"), delay);
realSetInterval(() => console.log("b"), delay);
setTimeout(() => console.log("c"), delay);
}
arm(1);
export {};
`,
);
// An untyped delay waits in a hidden local, so the call sits inside a
// seqExpr; the callee still decides whether mock.timers routes it.
expect(calls.map((call) => call.realTimer)).toEqual([true, true, undefined]);
expect(calls[0]).toMatchObject({ fn: "timers.setTimeout" });
expect(calls[1]).toMatchObject({ fn: "timers.setInterval" });
expect(calls[2]).toMatchObject({ fn: "timers.setTimeout" });
});
+8 -1
View File
@@ -2,7 +2,8 @@
// 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.
// never mocked: it schedules a real timer, including through an untyped
// delay, whose conversion runs before the call.
import { mock } from "node:test";
import { setTimeout as realSetTimeout } from "node:timers";
@@ -15,7 +16,13 @@ const interval = setInterval(() => {
clearInterval(interval);
}, 2 ** 31);
realSetTimeout(() => console.log("imported overflow"), 2 ** 32);
function scheduleImported(delay: any): void {
realSetTimeout(() => console.log("imported untyped delay"), delay);
}
scheduleImported(1);
setTimeout(() => console.log("mocked delay"), 1);
mock.timers.tick(1);
console.log("after tick");
mock.timers.reset();
setTimeout(() => console.log("real negative"), -5);