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:

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions