diff --git a/src/domain/javascript/javascriptSemanticAnalysis.timer_delays.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.timer_delays.test.ts index 9a3c54a34c..465740fe40 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.timer_delays.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.timer_delays.test.ts @@ -101,3 +101,58 @@ it("ignores later global code and shadowed eval for constructor identity", () => `), ).toEqual([]); }); + +it.each([ + ["Symbol", "configure(globalThis);"], + ["BigInt", "configure(globalThis);"], + ["Symbol", "const host = globalThis; configure(host);"], + ["BigInt", "configure({ host: globalThis });"], +])("retains %s delays and cancellation after %s", (constructor, effect) => { + const result = operations(` + import configure from "./configure.js"; + import { setTimeout, clearTimeout } from "node:timers"; + ${effect} + const handle = setTimeout(() => {}, ${constructor}("1")); + clearTimeout(handle); + `); + expect(result).toMatchObject([ + { kind: "schedule", resolution: "complete" }, + { kind: "cancel", resolution: "complete" }, + ]); + expect(result[1]?.linkedTimerId).toBe(result[0]?.timerId); +}); + +it("still rejects literal BigInt delays after the global object escapes", () => { + expect( + operations(` + import configure from "./configure.js"; + import { setTimeout, setInterval } from "node:timers"; + configure(globalThis); + setTimeout(() => {}, 1n); + setInterval(() => {}, -1n); + `), + ).toEqual([]); +}); + +it("does not let later or unrelated escapes change constructor identity", () => { + expect( + operations(` + import configure from "./configure.js"; + import { setTimeout, setInterval } from "node:timers"; + configure({}); + setTimeout(() => {}, Symbol("1")); + setInterval(() => {}, BigInt("1")); + configure(globalThis); + `), + ).toEqual([]); +}); + +it("selects a constructor before an argument can escape the global object", () => { + expect( + operations(` + import configure from "./configure.js"; + import { setTimeout } from "node:timers"; + setTimeout(() => {}, Symbol(configure(globalThis))); + `), + ).toEqual([]); +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 40315cc772..6fb46d96e5 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -19,7 +19,10 @@ import { } from "./javascriptSemanticState.js"; import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; -import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { + createGlobalReferenceReader, + isUnmodifiedGlobalReference, +} from "./javascriptSemanticAmbientWrites.js"; import { semanticOpaqueGlobalMayHaveChanged, semanticOpaqueNameMayHaveChanged, @@ -112,6 +115,10 @@ interface TimerCollectionInput { readonly ownerCallableId: string | null; readonly state: JavaScriptSemanticAnalysisState; readonly output: TimerCandidate[]; + readonly delayConstructors: ReadonlyMap< + string, + ReturnType + >; readonly ancestors: readonly t.Node[]; } @@ -123,6 +130,10 @@ export const collectJavaScriptSemanticAsyncEffects = ( ): AsyncEffectAnalysis => { const events: JavaScriptSemanticEventOperation[] = []; const timers: TimerCandidate[] = []; + const delayConstructors = new Map([ + ["Symbol", createGlobalReferenceReader(state, ["Symbol"])], + ["BigInt", createGlobalReferenceReader(state, ["BigInt"])], + ]); const callableIds = new Set(callables.map(({ callableId }) => callableId)); const callableStack = createSemanticCallableIdStack(callableIds); const ancestors: t.Node[] = []; @@ -137,6 +148,7 @@ export const collectJavaScriptSemanticAsyncEffects = ( ownerCallableId, state, output: timers, + delayConstructors, ancestors, }); } @@ -385,6 +397,7 @@ const schedulesWithNonFunctionCallback = ( const schedulesWithInvalidDelay = ( node: t.CallExpression | t.OptionalCallExpression, state: JavaScriptSemanticAnalysisState, + delayConstructors: TimerCollectionInput["delayConstructors"], ): boolean => { if ( node.arguments.slice(0, 2).some((argument) => t.isSpreadElement(argument)) @@ -403,8 +416,8 @@ const schedulesWithInvalidDelay = ( return ( t.isCallExpression(value) && t.isIdentifier(value.callee) && - ["BigInt", "Symbol"].includes(value.callee.name) && - isUnmodifiedGlobalReference(value.callee, state, value.callee.name) && + delayConstructors.get(value.callee.name)?.(value.callee).status === + "unmodified" && !semanticOpaqueNameMayHaveChanged(value.callee, state) && !semanticOpaqueGlobalMayHaveChanged(value.callee, state) ); @@ -420,7 +433,7 @@ const collectTimer = (input: TimerCollectionInput): void => { nodeBuiltinTimerMethod(node.callee, state) !== null && (schedulesWithNonFunctionCallback(node, state) || ((method === "setTimeout" || method === "setInterval") && - schedulesWithInvalidDelay(node, state))) + schedulesWithInvalidDelay(node, state, input.delayConstructors))) ) return; const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method)