Files
reverse-engineering-anything/src/domain/javascript/javascriptSemanticAsyncEffects.ts
T
吴佳浩Alben 54d819f382 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.
2026-11-04 19:45:02 +08:00

512 lines
17 KiB
TypeScript

import * as t from "@babel/types";
import type {
JavaScriptSemanticCallable,
JavaScriptSemanticProjectedResultBinding,
JavaScriptSemanticEventOperation,
JavaScriptSemanticTimerOperation,
} from "./javascriptSemanticIr.js";
import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js";
import {
readExactJavaScriptLiteral,
semanticStaticPropertyName,
unwrapJavaScriptExpression,
} from "./javascriptAstValues.js";
import {
resolveSemanticBindingState,
semanticResolutionBlocked,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
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,
dataEffectArgumentBindingId,
dataEffectLiteralString,
dataEffectMemberCallee,
assignedSemanticResultBindings,
scalarAssignedResultBinding,
semanticProjectedBindingFacts,
} from "./javascriptSemanticDataEffectHelpers.js";
const EVENT_REGISTER_METHODS = [
"on",
"once",
"addListener",
"prependListener",
"prependOnceListener",
] as const;
const EVENT_REMOVE_METHODS = [
"off",
"removeListener",
"removeAllListeners",
] as const;
/**
* Methods whose argument position 1 names a listener. `removeAllListeners`
* takes only an optional event name, and `emit` passes its remaining
* arguments as data to listeners that already exist.
*/
const EVENT_LISTENER_METHODS: readonly JavaScriptSemanticEventOperation["method"][] =
[...EVENT_REGISTER_METHODS, "off", "removeListener"];
const EVENT_METHODS = [
...EVENT_REGISTER_METHODS,
...EVENT_REMOVE_METHODS,
"emit",
] as const;
const TIMER_SCHEDULE_METHODS = [
"setTimeout",
"setInterval",
"setImmediate",
] as const;
const TIMER_CANCEL_METHODS = [
"clearTimeout",
"clearInterval",
"clearImmediate",
] as const;
interface AsyncEffectAnalysis {
readonly eventOperations: readonly JavaScriptSemanticEventOperation[];
readonly timerOperations: readonly JavaScriptSemanticTimerOperation[];
}
interface TimerCandidate {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly method: JavaScriptSemanticTimerOperation["method"];
readonly kind: JavaScriptSemanticTimerOperation["kind"];
readonly ownerCallableId: string | null;
readonly handleBindingId: string | null;
readonly projectedResultBindings: readonly JavaScriptSemanticProjectedResultBinding[];
readonly handleRead: {
readonly origins: readonly t.Node[];
readonly complete: boolean;
} | null;
}
interface TimerCollectionInput {
readonly node: t.CallExpression | t.OptionalCallExpression;
readonly ownerCallableId: string | null;
readonly state: JavaScriptSemanticAnalysisState;
readonly output: TimerCandidate[];
readonly ancestors: readonly t.Node[];
}
/** Recover explicit EventEmitter and Node timer candidates from inert syntax. */
export const collectJavaScriptSemanticAsyncEffects = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
callables: readonly JavaScriptSemanticCallable[],
): AsyncEffectAnalysis => {
const events: JavaScriptSemanticEventOperation[] = [];
const timers: TimerCandidate[] = [];
const callableIds = new Set(callables.map(({ callableId }) => callableId));
const callableStack = createSemanticCallableIdStack(callableIds);
const ancestors: t.Node[] = [];
traverseJavaScriptValueAst(program, state.erasedSyntaxNodes, {
enter: (node, parent) => {
callableStack.enter(node, parent);
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
const ownerCallableId = callableStack.callables.at(-1) ?? null;
collectEvent(node, ownerCallableId, state, events);
collectTimer({
node,
ownerCallableId,
state,
output: timers,
ancestors,
});
}
ancestors.push(node);
},
exit: (node) => {
ancestors.pop();
callableStack.exit(node);
},
});
return {
eventOperations: events,
timerOperations: resolveTimers(timers),
};
};
/**
* A listener must be a function, and Node throws `ERR_INVALID_ARG_TYPE` before
* anything is registered or removed when it is not. An exactly readable literal
* that cannot be a function is therefore not an event operation, while an
* identifier, a call result or a spread may still supply one and keeps its
* operation.
*
* A class expression passes Node's own `typeof` check, so it is kept.
*/
const rejectsEventListener = (listener: t.Node): boolean => {
if (t.isSpreadElement(listener)) return false;
const value = unwrapJavaScriptExpression(listener).node;
if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value))
return false;
if (t.isClassExpression(value)) return false;
// A numeric literal is always a number, including the non-finite value Babel
// stores for `1e999`, which the exact-value reader refuses to price.
if (t.isNumericLiteral(value)) return true;
// Every unary operator yields a primitive, and a primitive is never callable.
return (
t.isUnaryExpression(value) ||
readExactJavaScriptLiteral(value).found ||
t.isTemplateLiteral(value) ||
t.isBigIntLiteral(value) ||
t.isRegExpLiteral(value) ||
t.isArrayExpression(value) ||
t.isObjectExpression(value)
);
};
const collectEvent = (
node: t.CallExpression | t.OptionalCallExpression,
ownerCallableId: string | null,
state: JavaScriptSemanticAnalysisState,
output: JavaScriptSemanticEventOperation[],
): void => {
const member = dataEffectMemberCallee(node);
if (member === null) return;
const method = EVENT_METHODS.find(
(candidate) =>
candidate ===
semanticStaticPropertyName(member.property, member.computed),
);
if (method === undefined) return;
const kind = eventKind(method);
const eventArgument = node.arguments[0];
const listenerArgument = EVENT_LISTENER_METHODS.includes(method)
? node.arguments[1]
: undefined;
// A leading spread shifts the runtime argument positions, so argument 1 is not
// provably the listener: `e.on(...["x", fn], 1)` registers `fn` and ignores the
// trailing `1`.
if (
!t.isSpreadElement(eventArgument) &&
listenerArgument !== undefined &&
rejectsEventListener(listenerArgument)
)
return;
const emitter = emitterIdentity(member.object, state);
const eventName = dataEffectLiteralString(eventArgument);
output.push({
eventId: `event:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
kind,
method,
location: range(node),
ownerCallableId,
emitterKey: emitter.key,
emitterBindingId: emitter.bindingId,
eventName,
listenerBindingId: dataEffectArgumentBindingId(listenerArgument, state),
listenerLocation: t.isNode(listenerArgument)
? range(listenerArgument)
: null,
resolution:
eventName === null
? "unresolved"
: emitter.complete
? "complete"
: "partial",
});
};
const eventKind = (
method: JavaScriptSemanticEventOperation["method"],
): JavaScriptSemanticEventOperation["kind"] => {
if (EVENT_REGISTER_METHODS.some((candidate) => candidate === method))
return "register";
if (EVENT_REMOVE_METHODS.some((candidate) => candidate === method))
return "remove";
return "dispatch";
};
const emitterIdentity = (
node: t.Expression | t.Super,
state: JavaScriptSemanticAnalysisState,
): {
readonly key: string;
readonly bindingId: string | null;
readonly complete: boolean;
} => {
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding !== undefined) {
const read = semanticBindingAtRead(binding, node, state.parentsByNode);
const origins = [
...read.binding.initializers,
...read.binding.referenceInitializers,
];
const replaced = binding.definitions.some(
({ kind }) => kind === "assignment",
);
const origin = origins.length === 1 ? origins[0] : undefined;
const complete =
!replaced ||
(read.status === "ordered" &&
read.hasDefiniteInitializer &&
origin !== undefined);
return {
key: !replaced
? `binding:${binding.bindingId}`
: complete
? `binding:${binding.bindingId}:origin:${origin?.node.start ?? -1}:${origin?.node.end ?? -1}`
: `expression:${node.start ?? -1}:${node.end ?? -1}`,
bindingId: binding.bindingId,
complete,
};
}
return {
key: `global:${node.name}`,
bindingId: null,
complete: !semanticResolutionBlocked(state, node, node.name),
};
}
if (t.isThisExpression(node)) {
let owner = state.parentsByNode.get(node);
while (
owner !== undefined &&
!t.isClass(owner) &&
!(t.isFunction(owner) && !t.isArrowFunctionExpression(owner))
)
owner = state.parentsByNode.get(owner);
// Lexical receiver ownership does not establish one runtime instance.
return {
key: `this:${owner?.start ?? -1}:${owner?.end ?? -1}`,
bindingId: null,
complete: false,
};
}
return {
key: `expression:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
bindingId: null,
complete: false,
};
};
/**
* 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";
const projectedResultBindings = assignedSemanticResultBindings(
node,
ancestors,
state,
);
const handle =
kind === "cancel" && t.isIdentifier(node.arguments[0])
? resolveSemanticBindingState(
state,
node.arguments[0],
node.arguments[0].name,
)
: undefined;
const handleRead =
handle === undefined || !t.isIdentifier(node.arguments[0])
? null
: (() => {
const snapshot = semanticBindingAtRead(
handle,
node.arguments[0],
state.parentsByNode,
);
// A const handle has the same value inside a later closure even
// when the source positions belong to different function bodies.
const stableClosureCapture =
!handle.mutable &&
handle.initializers.length === 1 &&
handle.referenceInitializers.length === 0;
const origins = [
...snapshot.binding.initializers,
...snapshot.binding.referenceInitializers,
];
return {
origins: origins.map(({ node: origin }) => origin),
complete: snapshot.status === "ordered" || stableClosureCapture,
};
})();
output.push({
node,
method,
kind,
ownerCallableId,
handleBindingId:
kind === "schedule"
? scalarAssignedResultBinding(projectedResultBindings)
: dataEffectArgumentBindingId(node.arguments[0], state),
projectedResultBindings:
kind === "schedule"
? semanticProjectedBindingFacts(
projectedResultBindings.filter(
({ projectionPath }) => projectionPath.length > 0,
),
)
: [],
handleRead,
});
};
const resolveTimers = (
candidates: readonly TimerCandidate[],
): JavaScriptSemanticTimerOperation[] => {
const schedules = new Map<t.Node, TimerCandidate>(
candidates
.filter(({ kind }) => kind === "schedule")
.map((candidate) => [candidate.node, candidate] as const),
);
return candidates.map((candidate) => {
// Unordered reads retain possible initializers as candidate edges. Only an
// ordered or immutable read can establish one complete cancellation link.
const linked =
candidate.handleRead?.origins.flatMap((origin) => {
const schedule = schedules.get(origin);
return schedule === undefined ? [] : [schedule];
}) ?? [];
const incompleteOrigins =
candidate.handleRead !== null &&
candidate.handleRead.origins.length !== linked.length;
return {
timerId: timerId(candidate),
kind: candidate.kind,
method: candidate.method,
location: range(candidate.node),
ownerCallableId: candidate.ownerCallableId,
handleBindingId: candidate.handleBindingId,
projectedResultBindings: candidate.projectedResultBindings,
linkedTimerId:
linked.length === 1 && linked[0] !== undefined
? timerId(linked[0])
: null,
delayMilliseconds: timerDelay(candidate),
resolution:
candidate.kind === "schedule"
? "complete"
: linked.length === 1 &&
candidate.handleRead?.complete &&
!incompleteOrigins &&
!linked[0]?.projectedResultBindings.some(
({ bindingId }) => bindingId === candidate.handleBindingId,
)
? "complete"
: linked.length > 0
? "partial"
: "unresolved",
};
});
};
const timerMethod = (
callee: t.Node,
state: JavaScriptSemanticAnalysisState,
): JavaScriptSemanticTimerOperation["method"] | null => {
if (t.isIdentifier(callee)) {
const binding = resolveSemanticBindingState(state, callee, callee.name);
if (binding === undefined) {
const method = admittedTimerMethod(callee.name);
if (method === null) return null;
return semanticResolutionBlocked(state, callee, callee.name) ||
!isUnmodifiedGlobalReference(callee, state, callee.name)
? null
: method;
}
}
return admittedTimerMethod(
builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "",
);
};
const admittedTimerMethod = (
value: string,
): JavaScriptSemanticTimerOperation["method"] | null =>
[...TIMER_SCHEDULE_METHODS, ...TIMER_CANCEL_METHODS].find(
(candidate) => candidate === value,
) ?? null;
const timerDelay = (candidate: TimerCandidate): number | null => {
if (candidate.kind !== "schedule" || candidate.method === "setImmediate")
return null;
const delay = candidate.node.arguments[1];
// An overflowing literal such as 1e999 parses to Infinity, which JSON
// Evidence cannot carry; keep that delay unknown like any other.
return t.isNumericLiteral(delay) && Number.isFinite(delay.value)
? delay.value
: null;
};
const timerId = (candidate: TimerCandidate): string =>
`timer:${candidate.kind}:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`;