fix(javascript): emit no timer for a literal non-function callback (#8044)

* fix(javascript): emit no timer for a literal non-function callback

Node validates the timer callback before creating a handle:

    if (typeof callback !== 'function')
      throw new ERR_INVALID_ARG_TYPE('callback', 'function', callback);

The throw is synchronous, before any timer exists, but the analysis reported a
`schedule` operation for such calls.

Measured on Node 24.22.1:

    setTimeout("code", 0)    -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout(`code`, 0)    -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout(1, 0)         -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout(1n, 0)        -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout(true, 0)      -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout(null, 0)      -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout(void 0, 0)    -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout([], 0)        -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout({}, 0)        -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout()             -> THROWS ERR_INVALID_ARG_TYPE
    setImmediate("code")     -> THROWS ERR_INVALID_ARG_TYPE
    setInterval("code", 0)   -> THROWS ERR_INVALID_ARG_TYPE
    --- accepted by Node ---
    setTimeout(function(){}, 0) -> OK
    setTimeout(() => {}, 0)     -> OK
    setTimeout(fn, 0)           -> OK
    setTimeout(class {}, 0)     -> OK (fails only when the class is invoked)

So a schedule is dropped only for an exactly readable literal that is not a
function, or for a missing argument. An identifier, a call result or a spread may
still be a function at runtime and keeps its schedule.

* chore: retrigger title and readiness checks after the title was corrected

* fix(javascript): scope the timer callback rule to Node's timers module

greptile flagged two problems with the first version.

P1 - browser timers disappeared. The rule was applied to bare globals, but
browser and Electron renderer sources reach this analysis without a Node/browser
distinction, and there `setTimeout("refresh()", 0)` is a real timer: the host
evaluates the string. The rule now applies only when the timer is imported from
`node:timers`, where that form cannot exist. A bare global schedule keeps its
fact whatever argument 0 holds.

P2 - unused import. `readExactJavaScriptLiteral` was imported but never used; the
helper reads shapes through `unwrapJavaScriptExpression`. Removed.

Measured on Node 24.22.1 (the throw is what makes a node:timers schedule
impossible; the bare-global case is intentionally left alone):

    import { setTimeout } from "node:timers";
    setTimeout("code", 0)  -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout(null, 0)    -> THROWS ERR_INVALID_ARG_TYPE
    setTimeout(fn, 0)      -> OK
    --- bare global, host may be a browser ---
    setTimeout("code", 0)  -> kept as a schedule

24 cases cover both sides plus the cancellation link.

* fix(javascript): reject every non-function global and unary timer callback

Reviewing the timer callback guard against Node 24.22.1 found two gaps. Both are
in the same "a call that always throws must not emit a resolved fact" family.

**An unmodified global that is not a function was accepted.** The guard read
literal nodes only, so a bare global identifier passed through even though Node
rejects it 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

`isUnmodifiedGlobalReference` keeps a shadowed binding out of the rule, so a
local `const NaN = () => {}` keeps its schedule.

**Only `void` was treated as a non-function unary operator.** Every unary operator
yields a primitive, and Node rejects a primitive callback:

    setTimeout(!0)         -> THREW ERR_INVALID_ARG_TYPE
    setTimeout(~0)         -> THREW ERR_INVALID_ARG_TYPE
    setTimeout(-1)         -> THREW ERR_INVALID_ARG_TYPE
    setTimeout(+"1")       -> THREW ERR_INVALID_ARG_TYPE
    setTimeout(typeof 1)   -> THREW ERR_INVALID_ARG_TYPE

A class expression is still kept, because it passes Node's own `typeof` check and
only fails when invoked.

Fifteen regressions cover the rejected forms plus arrow, identifier, class,
spread and shadowed-global cases; ten fail on the previous commit.

* fix(javascript): let sloppy eval shadow the non-function global callback

Review finding. The new global check read `undefined`, `NaN` and `Infinity` as
unmodified globals, but sloppy `eval` can introduce a local function under one of
those names, which then shadows the global. Measured on Node 24.22.1 in script
scope:

    function g() { eval("var undefined = () => {}"); setTimeout(undefined, 0); }
    -> schedules a timer
    function g() { eval("var NaN = () => {}"); setTimeout(NaN, 0); }
    -> schedules a timer

`semanticOpaqueNameMayHaveChanged` now has to agree that no visible activation can
introduce such a name before the global reading is conclusive. In a module the
same `eval` is strict and introduces no outer binding, so the global reading still
holds and the timer is still dropped.

Three regressions cover the sloppy forms, and a fourth pins the no-eval cases as
still dropped. The three sloppy cases fail without the guard.
This commit is contained in:
吴佳浩Alben authored and GitHub committed 2026-11-04 19:45:02 +08:00
1 parent 848f165417
commit 54d819f382
2 files changed
+232

No files matched your search

@@ -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([]);
});
@@ -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";