Skip to content

AbortSignal.any(): observed composites still accumulate in a long-lived source's kDependantSignals after #62367 (not GC-pruned) #64476

Description

@denghongcai

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:

Activity

  1. denghongcai commented on Jul 13, 2026

    @denghongcai
    Author

    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, kDependantSignals is 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.

  2. bitpshr commented on Jul 13, 2026

    @bitpshr
    Contributor

    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 kDependantSignals are all live: a forced global.gc() plus a few event loop turns still leaves deref() returning the composite for every entry, with zero dead. So the composites genuinely aren't being collected.

    It looks like the retainer is gcPersistentSignals in lib/internal/abort_controller.js. When you add the abort listener, 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 composite kAborted and runs its abort steps, but it never removes the composite from gcPersistentSignals. 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 its WeakRef is never pruned from the source's kDependantSignals.

    An easy way to see it: if I add composite.removeEventListener('abort', handler) after the abort in the repro (which triggers the gcPersistentSignals.delete), kDependantSignals.size drops back to 0 after a GC. Without it, it stays at N.

    I think the fix is to drop each transitively-aborted dependent from gcPersistentSignals in 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions