mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
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:
co-authored by
Claude Opus 5.5
parent
84f0368f5f
commit
cdd121405b
@@ -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" } },
|
||||
});
|
||||
}
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/timer-optional-delays.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user