Convert number | undefined timer delays with ToNumber

setTimeout and setInterval passed their delay to the F64 timer slot
unconverted, so a delay typed number | undefined failed with SC2003. Such
a delay is an optional `ms?: number` passed on, or a number[] element
read, whose storage keeps the out-of-range undefined. Node converts the
delay with ToNumber, so undefined is NaN, the omitted delay. A union
delay now goes through lowerOptionalNumber, as Math arguments already
do. Other unions still reach the lib boundary's refusal, and number
delays are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-27 09:11:51 -07:00
co-authored by Claude Opus 5.5
parent 84f0368f5f
commit cdd121405b
5 changed files with 214 additions and 0 deletions
@@ -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 };
@@ -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<IrExpr[]> {
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" } },
});
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/timer-optional-delays.ts"
],
"diags": []
}
+120
View File
@@ -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<typeof setTimeout> {
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<void> {
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 {};
@@ -0,0 +1 @@
{ "plain": 4000, "san": 73500 }