diff --git a/packages/compiler/src/frontend/lowering/lower-timers.ts b/packages/compiler/src/frontend/lowering/lower-timers.ts index 03c9c5c1f..c76b2e667 100644 --- a/packages/compiler/src/frontend/lowering/lower-timers.ts +++ b/packages/compiler/src/frontend/lowering/lower-timers.ts @@ -15,6 +15,7 @@ import { typeKey, } from "../../ir/ir.js"; import { buildFunctionAdapter } from "./coercions/builders.js"; +import { lowerOptionalNumber } from "./lower-exprs.js"; import type { Lowerer } from "./lowerer.js"; /** setTimeout invokes its callback with NO arguments, but @types/node's @@ -409,6 +410,13 @@ function lowerTimerCall( type: F64, loc, }; + } else if (ms.type.kind === "union") { + // A delay that may be undefined: an optional `ms?: number` passed on, + // or an element read (`delays[0]`) whose storage keeps the + // out-of-range undefined. Node converts the delay with ToNumber + // (`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]); } if (member === "setInterval") { return { kind: "libCall", fn: "timers.setInterval", args: [cb, ms], type: F64, loc }; diff --git a/packages/compiler/test/timer-delay-lowering.test.ts b/packages/compiler/test/timer-delay-lowering.test.ts new file mode 100644 index 000000000..e4219c3c2 --- /dev/null +++ b/packages/compiler/test/timer-delay-lowering.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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 { + const dir = await mkdtemp(join(tmpdir(), "scriptc-timer-delays-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + 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 visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (value === null || typeof value !== "object") return; + const node = value as { kind?: unknown; fn?: unknown; args?: IrExpr[] }; + if ( + node.kind === "libCall" && + typeof node.fn === "string" && + /^timers\.set(Timeout|TimeoutHandle|Interval)$/.test(node.fn) + ) + delays.push(node.args![1]!); + for (const child of Object.values(value)) visit(child); + }; + visit(mod.functions); + return delays; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("number delays pass to the timer unchanged", async () => { + const delays = await timerDelays( + `function arm(ms: number): void { + setTimeout(() => console.log("a"), ms); + setTimeout(() => console.log("b"), 5); + const id = setInterval(() => clearInterval(id), ms); + } + arm(1); + export {}; + `, + ); + expect(delays.map((delay) => delay.kind)).toEqual(["varRef", "numLit", "varRef"]); + for (const delay of delays) expect(delay.type).toEqual({ kind: "f64" }); +}); + +test("a number | undefined delay converts with ToNumber", async () => { + const delays = await timerDelays( + `interface Options { ms?: number } + function arm(options: Options): void { + setTimeout(() => console.log("a"), options.ms); + const id = setInterval(() => clearInterval(id), options.ms); + } + arm({}); + export {}; + `, + ); + expect(delays).toHaveLength(2); + for (const delay of delays) { + // undefined is NaN, the omitted delay; a number passes through. + expect(delay).toMatchObject({ + kind: "ternary", + type: { kind: "f64" }, + cond: { kind: "unionIsTag" }, + then: { kind: "bin", op: "/" }, + else_: { kind: "unionNarrow", type: { kind: "f64" } }, + }); + } +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timer-optional-delays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timer-optional-delays.ts.json new file mode 100644 index 000000000..f95051960 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/timer-optional-delays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/timer-optional-delays.ts" + ], + "diags": [] +} diff --git a/tests/corpus/timer-optional-delays.ts b/tests/corpus/timer-optional-delays.ts new file mode 100644 index 000000000..788d6a193 --- /dev/null +++ b/tests/corpus/timer-optional-delays.ts @@ -0,0 +1,120 @@ +// setTimeout and setInterval delays typed `number | undefined`: an optional +// `ms?: number` passed on, a number[] element read (its storage keeps the +// out-of-range undefined), and a read past the end. Node converts the delay +// with ToNumber, so undefined is NaN and the timer waits the default 1ms, +// like an omitted delay. Timers sharing the 1ms bucket fire in registration +// order; the other timers report through a counter so wall-clock timing +// never decides the output. + +interface RefreshOptions { + label: string; + timeoutMs?: number; +} + +const DELAYS = [1, 5]; +const firstBucket: string[] = []; +const events: string[] = []; +let pending = 0; + +function track(): () => void { + pending++; + return () => { + pending--; + if (pending === 0) { + console.log("1ms bucket:", firstBucket.join(", ")); + console.log("events:", events.sort().join(", ")); + } + }; +} + +// An optional field passed on, absent and present; the handle form clears. +function schedule(options: RefreshOptions): ReturnType { + const done = track(); + return setTimeout(() => { + firstBucket.push(options.label); + done(); + }, options.timeoutMs); +} +schedule({ label: "absent" }); +schedule({ label: "one", timeoutMs: 1 }); + +// Element reads in statement position: in range, and past the end. +const doneFirst = track(); +setTimeout(() => { + firstBucket.push("DELAYS[0]"); + doneFirst(); +}, DELAYS[0]); +const doneMissing = track(); +setTimeout(() => { + firstBucket.push("DELAYS[9]"); + doneMissing(); +}, DELAYS[9]); + +const cancelled = schedule({ label: "cancelled", timeoutMs: DELAYS[1] }); +clearTimeout(cancelled); +pending--; + +// A binding initialized from an element read. +const retry = DELAYS[1]; +const doneRetry = track(); +setTimeout(() => { + events.push(`retry after ${retry}`); + doneRetry(); +}, retry); + +// A guard that narrows the controller, not the delay. +function withTimeout(ms?: number): void { + const controller = ms !== undefined ? new AbortController() : undefined; + const done = controller ? track() : undefined; + const timer = controller + ? setTimeout(() => { + controller.abort(); + events.push(`aborted ${controller.signal.aborted}`); + done?.(); + }, ms) + : undefined; + if (timer === undefined) events.push("no timer"); +} +withTimeout(); +withTimeout(3); + +// An interval with an optional delay, cleared through its handle. +function tickTwice(label: string, ms?: number): void { + const done = track(); + let ticks = 0; + const id = setInterval(() => { + ticks++; + if (ticks === 2) { + clearInterval(id); + events.push(`${label} ticked ${ticks}`); + done(); + } + }, ms); +} +tickTwice("default interval"); +tickTwice("element interval", DELAYS[0]); + +// The sleep idiom with an element delay. +async function sleepThenLog(): Promise { + const done = track(); + await new Promise((resolve) => setTimeout(resolve, DELAYS[0])); + events.push("slept"); + done(); +} +void sleepThenLog(); + +// A narrowed delay keeps the plain number path. +function narrowed(ms: number | undefined): void { + if (ms === undefined) return; + const done = track(); + setTimeout(() => { + events.push(`narrowed ${ms + 1}`); + done(); + }, ms); +} +narrowed(2); +narrowed(undefined); + +console.log("scheduled", pending); + +export {}; diff --git a/tests/harness/corpus-costs/timer-optional-delays.ts.json b/tests/harness/corpus-costs/timer-optional-delays.ts.json new file mode 100644 index 000000000..fa2c86926 --- /dev/null +++ b/tests/harness/corpus-costs/timer-optional-delays.ts.json @@ -0,0 +1 @@ +{ "plain": 4000, "san": 73500 }