Follow-up to #62363 / #62367.
#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:
Follow-up to #62363 / #62367.
#62367 ("defer
AbortSignal.any()following") stops listener-less composites from following their sources eagerly. But a composite that is observed (has anabortlistener) still follows its sources, and when one of those sources is long-lived, the composite's entry accumulates in that source'skDependantSignalsand is not pruned even by a forcedglobal.gc().sizegrows 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).Version
v24.18.0(v8 13.6.233.17-node.50),v22.12.0(v8 12.4.254.21-node.21), andv26.5.0(v8 14.6.202.34-node.24).Platform
macOS (darwin), installed via nvm.
Subsystem
abortcontroller/lib/internal/abort_controller.jsRepro A — composite is aborted
Repro B — composite is never aborted (normal-completion path)
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.sizeon the long-lived source grows monotonically and is not reclaimed by a forced full GC. A fresh-source control (Repro Amode=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
gcPersistentSignalsretaining the aborted composite — the abort path marks it aborted but never drops it, so on a long-lived source it's retained forever and itsWeakRefnever gets pruned. #64481 fixes that by dropping transitively-aborted dependents fromgcPersistentSignals, which is the clear, actionable bug here.To correct my original "aborting or not doesn't matter" wording — the two cases differ:
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.