Repository navigation
AbortSignal.any(): observed composites still accumulate in a long-lived source's kDependantSignals after #62367 (not GC-pruned) #64476
Description
#62367 ("defer AbortSignal.any() following") stops listener-less composites from following their sources eagerly. But a composite that is observed (has an abort listener) still follows its sources, and when one of those sources is long-lived, the composite's entry accumulates in that source's kDependantSignals and is not pruned even by a forced global.gc(). size grows monotonically for as long as the process runs.
Reproduces on v24.18.0 (which contains #62367), back on v22.12.0, and on the latest v26.5.0 (v8 14.6).
Note (edited after #64481): My original wording said "aborting or not doesn't matter; both leak" but only attached the aborted repro. Both cases are shown below now (Repro A = aborted, Repro B = never aborted). They have different causes — see the Update at the bottom.
Version
v24.18.0 (v8 13.6.233.17-node.50), v22.12.0 (v8 12.4.254.21-node.21), and v26.5.0 (v8 14.6.202.34-node.24).
Platform
macOS (darwin), installed via nvm.
Subsystem
abortcontroller / lib/internal/abort_controller.js
Repro A — composite is aborted
// node --expose-gc reproA.mjs [shared|fresh] (shared = long-lived source)
const mode = process.argv[2] || 'shared';
const longLived = new AbortController(); // never aborted; e.g. an app-lifetime "shutdown" signal
const N = 500_000;
const kDep = () => Object.getOwnPropertySymbols(longLived.signal)
.find((s) => s.toString() === 'Symbol(kDependantSignals)');
for (let i = 1; i <= N; i++) {
const source = mode === 'shared' ? longLived : new AbortController();
const perReq = new AbortController();
const composite = AbortSignal.any([perReq.signal, source.signal]);
composite.addEventListener('abort', () => {}); // "observed": composite follows its sources
perReq.abort(); // settle, then discard composite/perReq
if (i % 100_000 === 0) {
globalThis.gc(); // force a full GC
const sym = kDep();
console.log(`${i} iters | longLived.kDependantSignals.size = ${sym ? longLived.signal[sym].size : 0}`);
}
}
console.log(`node ${process.version} | v8 ${process.versions.v8} | mode=${mode}`);### mode=shared (long-lived source) ###
100000 iters | longLived.kDependantSignals.size = 100000
...
500000 iters | longLived.kDependantSignals.size = 500000
### mode=fresh (control) ###
... size = 0 (flat)
Repro B — composite is never aborted (normal-completion path)
// node --expose-gc reproB.mjs
const longLived = new AbortController();
const N = 500_000;
const kDep = () => Object.getOwnPropertySymbols(longLived.signal)
.find((s) => s.toString() === 'Symbol(kDependantSignals)');
for (let i = 1; i <= N; i++) {
const perReq = new AbortController();
const composite = AbortSignal.any([perReq.signal, longLived.signal]);
composite.addEventListener('abort', () => {}); // observed
// no abort() — the "request" just completes and the composite is dropped
if (i % 100_000 === 0) {
globalThis.gc();
const sym = kDep();
console.log(`${i} iters | longLived.kDependantSignals.size = ${sym ? longLived.signal[sym].size : 0}`);
}
}
console.log(`node ${process.version} | v8 ${process.versions.v8} | never-aborted`);### v26.5.0 — observed, never aborted ###
100000 iters | longLived.kDependantSignals.size = 100000
...
500000 iters | longLived.kDependantSignals.size = 500000
Expected behavior
Per the DOM Standard, an AbortSignal's dependent signals and source signals are each "a weak set". Entries for composites that can no longer usefully fire should not accumulate indefinitely on a long-lived source.
What you see instead
kDependantSignals.size on the long-lived source grows monotonically and is not reclaimed by a forced full GC. A fresh-source control (Repro A mode=fresh) stays flat at 0.
Version coverage
Both repros reproduce on v22.12.0, v24.6.0, v24.13.1, v24.18.0, and v26.5.0.
Update (after #64481)
Thanks @bitpshr. The root cause for Repro A is gcPersistentSignals retaining the aborted composite — the abort path marks it aborted but never drops it, so on a long-lived source it's retained forever and its WeakRef never gets pruned. #64481 fixes that by dropping transitively-aborted dependents from gcPersistentSignals, which is the clear, actionable bug here.
To correct my original "aborting or not doesn't matter" wording — the two cases differ:
- Repro A (aborted): the bug lib: fix AbortSignal.any() observed-composite leak #64481 fixes. ✅
- Repro B (never aborted): still accumulates (verified on v26.5.0 above). But I suspect this one may be by design: an observed composite whose source is still alive has to remain reachable so it can fire if the source aborts later, so
gcPersistentSignalscan't drop it while the source lives (cf. doc: discourage AbortSignal cleanup for long-lived resources #64342). Flagging it only so it's a conscious call whether B is in-scope or expected — if it's expected/"remove the listener", that's a fine resolution and lib: fix AbortSignal.any() observed-composite leak #64481 covers the actionable part.
Activity
denghongcai commentedon Jul 13, 2026
Additional data point: this still reproduces on the latest v26.5.0 (
v8 14.6.202.34-node.24).Using the same repro from the issue:
For contrast, on the same v26.5.0 the listener-less #62363 repro is fine (flat ~61 MiB,
kDependantSignalsis never even created — #62367's defer-following working).This may be relevant to the "same root cause as #62363, or distinct?" question: #62363 was attributed to the V8 "Don't pretenure WeakCells" change, with a fix around V8 14.3. v26.5.0 ships V8 14.6 (past that), and the listener-less case is indeed fixed — yet the observed case still accumulates. Combined with it reproducing back on v22.12.0 (which predates that V8 change), this leans toward the observed-composite retention being a separate issue that "defer following" doesn't reach, rather than the WeakCell/pretenuring pacing behavior. But I'll defer to those who know the internals.
Version coverage so far: reproduces on v22.12.0, v24.6.0, v24.13.1, v24.18.0, and v26.5.0.
bitpshr commentedon Jul 13, 2026
Confirming this reproduces; I think it's a Node-side retention rather than the #62363 V8 pretenuring behavior. The accumulated entries in the long-lived source's
kDependantSignalsare all live: a forcedglobal.gc()plus a few event loop turns still leavesderef()returning the composite for every entry, with zero dead. So the composites genuinely aren't being collected.It looks like the retainer is
gcPersistentSignalsinlib/internal/abort_controller.js. When you add theabortlistener, the observed composite gets added to that strong set so it stays alive long enough to fire. When one of its sources aborts,abortSignal()marks the compositekAbortedand runs its abort steps, but it never removes the composite fromgcPersistentSignals. That set only gets pruned when the listener is explicitly removed or when a source is GC'd, and with a long-lived source neither happens. So the aborted composite, which can never fire again, is retained forever, never GC'd, and itsWeakRefis never pruned from the source'skDependantSignals.An easy way to see it: if I add
composite.removeEventListener('abort', handler)after the abort in the repro (which triggers thegcPersistentSignals.delete),kDependantSignals.sizedrops back to 0 after a GC. Without it, it stays at N.I think the fix is to drop each transitively-aborted dependent from
gcPersistentSignalsin the abort path, the same way the directly-aborted signal is already removed a few lines down. Happy to open a PR with that plus a gc-based regression test.lib: fix AbortSignal.any() observed-composite leak
lib: fix AbortSignal.any() observed-composite leak
lib: fix AbortSignal.any() observed-composite leak
lib: fix AbortSignal.any() observed-composite leak
lib: fix AbortSignal.any() observed-composite leak
lib: fix AbortSignal.any() observed-composite leak