diff --git a/src/domain/javascript/javascriptSemanticAnalysis.timers.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.timers.test.ts index 0df828281b..7590ebf588 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.timers.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.timers.test.ts @@ -87,3 +87,37 @@ it("keeps cancellation uncertain across a mutable closure capture", () => { resolution: "partial", }); }); + +it("keeps cancellation within the handle family that Node clears", () => { + // Node keeps timeouts and immediates in separate handle families: + // clearTimeout/clearInterval act only on a Timeout, clearImmediate only on an + // Immediate. Clearing within a family always cancels; clearing across them is + // runtime-dependent (setImmediate then clearTimeout never cancels, but + // setTimeout then clearImmediate does on some supported Node versions), so a + // cross-family clear is retained as unresolved rather than as proven either way. + const pairs = [ + ["setTimeout", "clearTimeout", true], + ["setTimeout", "clearInterval", true], + ["setTimeout", "clearImmediate", false], + ["setInterval", "clearTimeout", true], + ["setInterval", "clearInterval", true], + ["setInterval", "clearImmediate", false], + ["setImmediate", "clearTimeout", false], + ["setImmediate", "clearInterval", false], + ["setImmediate", "clearImmediate", true], + ] as const; + + for (const [schedule, cancel, linked] of pairs) { + const operations = timerOperations(` + const handle = ${schedule}(work, 10); + ${cancel}(handle); + `); + + expect(operations[1], `${schedule} then ${cancel}`).toMatchObject({ + kind: "cancel", + method: cancel, + linkedTimerId: linked ? operations[0]?.timerId : null, + resolution: linked ? "complete" : "unresolved", + }); + } +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index e88832eaa4..1033f6e38f 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -68,6 +68,24 @@ const TIMER_CANCEL_METHODS = [ "clearImmediate", ] as const; +/** + * Node keeps two timer handle families. `clearTimeout` and `clearInterval` are + * aliases that act only on a `Timeout` from `setTimeout` or `setInterval`, and + * `clearImmediate` acts only on an `Immediate` from `setImmediate`. Clearing + * within a family always cancels. Clearing across the families is + * runtime-dependent: `setImmediate` then `clearTimeout`/`clearInterval` never + * cancels, but `setTimeout` then `clearImmediate` reports as cancelled on some + * supported Node versions and fires on others. The analysis therefore treats a + * cross-family clear as unresolved rather than as proof that the timer kept + * running. + */ +const timerHandleFamily = ( + method: JavaScriptSemanticTimerOperation["method"], +): "timeout" | "immediate" => + method === "setImmediate" || method === "clearImmediate" + ? "immediate" + : "timeout"; + interface AsyncEffectAnalysis { readonly eventOperations: readonly JavaScriptSemanticEventOperation[]; readonly timerOperations: readonly JavaScriptSemanticTimerOperation[]; @@ -435,7 +453,15 @@ const resolveTimers = ( const linked = candidate.handleRead?.origins.flatMap((origin) => { const schedule = schedules.get(origin); - return schedule === undefined ? [] : [schedule]; + // A handle always clears its own family. Clearing across families is + // runtime-dependent, so it establishes no link in either direction. + if ( + schedule === undefined || + timerHandleFamily(schedule.method) !== + timerHandleFamily(candidate.method) + ) + return []; + return [schedule]; }) ?? []; const incompleteOrigins = candidate.handleRead !== null &&