diff --git a/src/domain/javascript/javascriptSemanticAnalysis.timer_callbacks.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.timer_callbacks.test.ts new file mode 100644 index 0000000000..3587a2c7d0 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.timer_callbacks.test.ts @@ -0,0 +1,162 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * A schedule imported from Node's `timers` module requires a function in + * argument 0. Node validates it before creating a handle, so a provable literal + * that is not a function throws `ERR_INVALID_ARG_TYPE: The "callback" argument + * must be of type function` synchronously and no timer exists. + * + * Only exactly readable literals are decided: an identifier or a call may still + * be a function at runtime. + */ +const nodeTimersCalls = (call: string) => + analyzeJavaScriptSemantics( + `import { setTimeout, setInterval, setImmediate } from "node:timers"; function g(fn) { ${call}; }`, + ).timerOperations.map(({ kind, method }) => ({ kind, method })); + +/** A bare global timer may belong to a browser, where a string is evaluated. */ +const globalTimerCalls = (call: string) => + analyzeJavaScriptSemantics(`function g(fn) { ${call}; }`).timerOperations.map( + ({ kind, method }) => ({ kind, method }), + ); + +it.each([ + ["a string callback", `setTimeout("code", 0)`], + ["a template-literal callback", "setTimeout(`code`, 0)"], + ["a numeric callback", `setTimeout(1, 0)`], + ["a BigInt callback", `setTimeout(1n, 0)`], + ["a boolean callback", `setTimeout(true, 0)`], + ["a null callback", `setTimeout(null, 0)`], + ["a void callback", `setTimeout(void 0, 0)`], + ["an array callback", `setTimeout([], 0)`], + ["an object callback", `setTimeout({}, 0)`], + ["no arguments at all", `setTimeout()`], + ["a setImmediate string callback", `setImmediate("code")`], + ["a setInterval string callback", `setInterval("code", 0)`], +] as const)("emits no node timers schedule for %s", (_name, call) => { + expect(nodeTimersCalls(call)).toEqual([]); +}); + +it.each([ + ["a function expression", `setTimeout(function () {}, 0)`, "setTimeout"], + ["an arrow function", `setTimeout(() => {}, 0)`, "setTimeout"], + ["an identifier", `setTimeout(fn, 0)`, "setTimeout"], + ["an identifier without a delay", `setTimeout(fn)`, "setTimeout"], + ["a call result", `setTimeout(select(), 0)`, "setTimeout"], + ["a class expression", `setTimeout(class {}, 0)`, "setTimeout"], + ["a resolved setImmediate", `setImmediate(fn)`, "setImmediate"], + ["a resolved setInterval", `setInterval(fn, 10)`, "setInterval"], +] as const)("keeps the node timers schedule for %s", (_name, call, method) => { + expect(nodeTimersCalls(call)).toEqual([{ kind: "schedule", method }]); +}); + +/** + * Browser and Electron renderer sources reach this analysis without a + * Node/browser distinction, and there a string callback is evaluated by the + * host. A bare global timer therefore keeps its schedule whatever the argument. + */ +it.each([ + ["a string callback", `setTimeout("code", 0)`], + ["a string setInterval callback", `setInterval("code", 0)`], + ["a null callback", `setTimeout(null, 0)`], +] as const)("keeps the bare global timer for %s", (_name, call) => { + expect(globalTimerCalls(call)).toHaveLength(1); +}); + +it("keeps a cancellation whose handle is a real timer", () => { + const operations = analyzeJavaScriptSemantics(` + import { setTimeout } from "node:timers"; + const handle = setTimeout(() => {}, 10); + clearTimeout(handle); + `).timerOperations; + expect(operations.map(({ kind }) => kind)).toEqual(["schedule", "cancel"]); + expect(operations[1]?.resolution).toBe("complete"); +}); + +/** + * `undefined`, `NaN` and `Infinity` are unmodified globals and none is a + * function, and every unary operator yields a primitive rather than a function. + * Node rejects each before creating a handle: + * + * setTimeout(undefined) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(NaN) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(Infinity) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(!0) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(-1) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(typeof 1) -> THREW ERR_INVALID_ARG_TYPE + * --- still scheduled --- + * setTimeout(() => {}) -> ok + * setTimeout(class {}) -> ok, a class passes Node's typeof check + */ +it.each([ + ["an undefined callback", `setTimeout(undefined)`], + ["a NaN callback", `setTimeout(NaN)`], + ["an Infinity callback", `setTimeout(Infinity)`], + ["a void callback", `setTimeout(void 0)`], + ["a logical-not callback", `setTimeout(!0)`], + ["a bitwise-not callback", `setTimeout(~0)`], + ["a negated callback", `setTimeout(-1)`], + ["a unary-plus callback", `setTimeout(+"1")`], + ["a typeof callback", `setTimeout(typeof 1)`], + ["a NaN immediate callback", `setImmediate(NaN)`], + ["an Infinity interval callback", `setInterval(Infinity)`], +] as const)("emits no timer for %s", (_name, call) => { + expect(nodeTimersCalls(call)).toEqual([]); +}); + +it.each([ + ["an arrow callback", `setTimeout(() => {})`, "setTimeout"], + ["an identifier callback", `setTimeout(handler)`, "setTimeout"], + ["a class callback", `setTimeout(class {})`, "setTimeout"], + ["an immediate arrow callback", `setImmediate(() => {})`, "setImmediate"], +] as const)("keeps the timer for %s", (_name, call, method) => { + expect(nodeTimersCalls(call)).toEqual([{ kind: "schedule", method }]); +}); + +it("keeps the timer when a local binding shadows the global", () => { + expect(nodeTimersCalls(`const NaN = () => {}; setTimeout(NaN)`)).toEqual([ + { kind: "schedule", method: "setTimeout" }, + ]); + expect( + nodeTimersCalls(`const undefined = () => {}; setTimeout(undefined)`), + ).toEqual([{ kind: "schedule", method: "setTimeout" }]); +}); + +/** + * Sloppy `eval` can introduce a local binding named `undefined`, `NaN` or + * `Infinity`, which then shadows the global the guard would otherwise read. The + * opaque-effects owner has to agree before the global reading is conclusive: + * + * function g() { eval("var undefined = () => {}"); setTimeout(undefined, 0); } + * -> schedules a timer in Node (sloppy script scope) + * + * In a module the same `eval` is strict and introduces no outer binding, so the + * global reading still holds. + */ +const sloppyTimersCalls = (call: string) => + analyzeJavaScriptSemantics( + `const { setTimeout } = require("node:timers"); function g() { ${call}; }`, + ).timerOperations.map(({ kind, method }) => ({ kind, method })); + +it.each([ + [ + "an eval-introduced undefined", + `eval('var undefined = () => {}'); setTimeout(undefined, 0)`, + ], + ["an eval-introduced NaN", `eval('var NaN = () => {}'); setTimeout(NaN, 0)`], + [ + "an eval-introduced Infinity", + `eval('var Infinity = () => {}'); setTimeout(Infinity, 0)`, + ], +] as const)("keeps the timer after %s", (_name, call) => { + expect(sloppyTimersCalls(call)).toEqual([ + { kind: "schedule", method: "setTimeout" }, + ]); +}); + +it("still drops a global callback when no eval can shadow it", () => { + expect(sloppyTimersCalls(`setTimeout(undefined, 0)`)).toEqual([]); + expect(sloppyTimersCalls(`setTimeout(NaN, 0)`)).toEqual([]); +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index ea9e990bd1..4c5d56fa4b 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -21,6 +21,7 @@ import { import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { semanticOpaqueNameMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { builtinDataEffectMethod, @@ -286,10 +287,79 @@ const emitterIdentity = ( }; }; +/** + * A timer proven to come from the Node `timers` module rather than a bare + * global. Browser and Electron renderer sources reach this analysis without a + * Node/browser distinction, and there `setTimeout("refresh()", 0)` is a real + * timer: the string is evaluated by the host. Node's callback rule therefore + * applies only when the timer is imported from `node:timers`, where that form + * cannot exist. + */ +const nodeBuiltinTimerMethod = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticTimerOperation["method"] | null => + admittedTimerMethod( + builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "", + ); + +/** + * A schedule imported from Node's `timers` module requires a function in + * argument 0. Node validates it before creating a handle, so a provable literal + * that is not a function throws ERR_INVALID_ARG_TYPE synchronously and no timer + * exists. Only literals the analyzer can read exactly are decided; an identifier + * or call may still be a function at runtime. + */ +const schedulesWithNonFunctionCallback = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return false; + const argument = node.arguments[0]; + // No argument at all cannot be a function. A spread may still expand to one. + if (argument === undefined) return true; + if (t.isSpreadElement(argument)) return false; + const value = unwrapJavaScriptExpression(argument).node; + if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value)) + return false; + if ( + t.isStringLiteral(value) || + t.isNumericLiteral(value) || + t.isBigIntLiteral(value) || + t.isBooleanLiteral(value) || + t.isNullLiteral(value) || + t.isTemplateLiteral(value) || + t.isArrayExpression(value) || + t.isObjectExpression(value) + ) + return true; + // `undefined`, `NaN` and `Infinity` are unmodified globals, and none is a + // function. A shadowed binding may still hold one, so only the global counts. + if ( + t.isIdentifier(value) && + ["undefined", "NaN", "Infinity"].includes(value.name) && + isUnmodifiedGlobalReference(value, state, value.name) && + !semanticOpaqueNameMayHaveChanged(value, state) + ) + return true; + // A class passes Node's own typeof check; invoking it is a separate failure, + // so the schedule itself still happens. Every other unary operator (including + // `void`, `!`, `~`, `typeof` and `delete`) yields a primitive, never a + // function. + return t.isUnaryExpression(value); +}; + const collectTimer = (input: TimerCollectionInput): void => { const { node, ownerCallableId, state, output, ancestors } = input; const method = timerMethod(node.callee, state); if (method === null) return; + if ( + TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) && + nodeBuiltinTimerMethod(node.callee, state) !== null && + schedulesWithNonFunctionCallback(node, input.state) + ) + return; const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) ? "schedule" : "cancel";