mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
* 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.
163 lines
6.7 KiB
TypeScript
163 lines
6.7 KiB
TypeScript
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([]);
|
|
});
|