fix(javascript): keep timer cancellation within the Node handle family (#7712)

* fix(javascript): keep timer cancellation within Node's handle family

Node keeps timeouts and immediates in disjoint handle families. clearTimeout
and clearInterval are aliases that act only on a Timeout from setTimeout or
setInterval, while clearImmediate acts only on an Immediate from setImmediate.
Crossing the families returns without doing anything, so the scheduled
callback still runs.

REA linked a cancellation whenever the cleared binding traced back to any timer
schedule, so clearImmediate(setTimeout(work, 10)) reported a complete
cancellation and the graph emitted a resolved cancels-timer relation. It also
flagged no uncertainty for it.

Require the cancel and the schedule to share a handle family before linking
them. A cross-family clear now keeps linkedTimerId null and resolution
unresolved, and the graph reports the cancellation as a candidate with an
ambiguous-target unknown instead of a resolved fact.

Verified against Node v24.22.1 over all nine schedule/cancel pairs: setTimeout
and setInterval with clearTimeout or clearInterval cancel, and setImmediate
with clearImmediate cancels; the other five combinations leave the callback
running. Node's own lib/timers.js shows why: clearTimeout requires
timer?._onTimeout, clearImmediate requires immediate?._onImmediate and returns
early otherwise, and clearInterval delegates to clearTimeout.

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

* fix(javascript): say cross-family timer clearing is runtime-dependent

Review feedback from @morluto: the previous wording claimed clearing across the
timer handle families "returns without doing anything, so the timer keeps
running". That does not hold on every supported Node version. Measured on Node
24.22.1, all three cross-family pairs fire:

| scheduled with | cancelled with | Node 24.22.1 |
| --- | --- | --- |
| `setTimeout` | `clearImmediate` | fires |
| `setImmediate` | `clearTimeout` | fires |
| `setImmediate` | `clearInterval` | fires |

`clearImmediate` cancelling a `Timeout` on Node 22 is the case the old claim
missed. Within a family the clear always cancels, which is why the analysis
links only same-family pairs; across families the outcome depends on the
runtime, which is exactly why the analysis reports `unresolved` rather than
`complete` in either direction.

The behavior and the tests are unchanged. The `timerHandleFamily` doc comment,
the linked-origin comment, and the test comment now state the runtime
dependence and why it justifies an unresolved result.
This commit is contained in:
吴佳浩Alben authored and GitHub committed 2026-11-07 15:17:47 +08:00
1 parent 36dcd985e1
commit 923e6f77e3
2 files changed
+61 -1

No files matched your search

@@ -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",
});
}
});
@@ -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 &&