Repository navigation
AbortSignal.any(): observed composites still accumulate in a long-lived source's kDependantSignals after #62367 (not GC-pruned) #64476
Description
Activity
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:
### mode=shared (long-lived source) — v26.5.0 ### 100000 iters | longLived.kDependantSignals.size = 100000 200000 iters | longLived.kDependantSignals.size = 200000 300000 iters | longLived.kDependantSignals.size = 300000 400000 iters | longLived.kDependantSignals.size = 400000 500000 iters | longLived.kDependantSignals.size = 500000 node v26.5.0 | v8 14.6.202.34-node.24 | mode=shared ### mode=fresh (control) — v26.5.0 ### ... size = 0 (flat)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.
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.- added a commit that references this issue
on Jul 26, 2026 - added a commit that references this issue
on Jul 29, 2026
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.