From 1204b62e92da1826b243f6606ac80265c1714670 Mon Sep 17 00:00:00 2001 From: Paul Bouchon Date: Mon, 13 Jul 2026 15:35:47 -0400 Subject: [PATCH] lib: fix AbortSignal.any() observed-composite leak An observed composite signal (one with an `abort` listener) is added to gcPersistentSignals so it stays alive long enough to fire. When one of its sources aborted, the composite was marked aborted but never removed from that set, so it was retained forever and its WeakRef was never pruned from a long-lived source's kDependantSignals. That set therefore grew without bound. Drop each transitively-aborted dependent from gcPersistentSignals, the same way the directly-aborted signal is already removed, so it can be collected and its dependant entries pruned. Fixes: https://github.com/nodejs/node/issues/64476 Signed-off-by: Paul Bouchon --- lib/internal/abort_controller.js | 7 +++++ .../test-abortsignal-drop-settled-signals.mjs | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/lib/internal/abort_controller.js b/lib/internal/abort_controller.js index a24b5b556e1a5e..09b160e9fe5aa8 100644 --- a/lib/internal/abort_controller.js +++ b/lib/internal/abort_controller.js @@ -522,6 +522,13 @@ function abortSignal(signal, reason) { for (let i = 0; i < dependentSignalsToAbort.length; i++) { const dependentSignal = dependentSignalsToAbort[i]; runAbort(dependentSignal); + // A transitively-aborted dependent signal can never abort again, so there + // is no longer any reason to keep it alive. Dropping it from + // gcPersistentSignals lets it be collected, which in turn prunes its + // WeakRef from its sources' kDependantSignals sets. Otherwise, an observed + // composite that follows a long-lived source stays retained forever and + // its entry accumulates in that source's kDependantSignals. + gcPersistentSignals.delete(dependentSignal); } // Clean up the signal from gcPersistentSignals diff --git a/test/parallel/test-abortsignal-drop-settled-signals.mjs b/test/parallel/test-abortsignal-drop-settled-signals.mjs index c481db069d6f13..224d65abc70f5f 100644 --- a/test/parallel/test-abortsignal-drop-settled-signals.mjs +++ b/test/parallel/test-abortsignal-drop-settled-signals.mjs @@ -142,6 +142,37 @@ describe('when there is a long-lived signal', () => { run(1); }); + + it('drops observed dependent signals once they are transitively aborted', async () => { + const longLived = new AbortController(); + const handler = () => {}; + const size = () => { + const sym = Object.getOwnPropertySymbols(longLived.signal).find( + (s) => s.toString() === 'Symbol(kDependantSignals)' + ); + return sym ? longLived.signal[sym].size : 0; + }; + + // Each composite observes the long-lived source and a per-request source, + // then is aborted through the per-request source without ever removing its + // listener. The aborted composites can never fire again, so the long-lived + // source's dependant set must not accumulate them. Using a helper keeps the + // last iteration's signals from lingering on the stack for the assertion. + const createObservedAbortedComposite = () => { + const perReq = new AbortController(); + const composite = AbortSignal.any([perReq.signal, longLived.signal]); + composite.addEventListener('abort', handler); + perReq.abort(); + }; + for (let i = 0; i < limit; i++) { + createObservedAbortedComposite(); + } + + await gcUntil( + 'observed dependents are dropped after transitive abort', + () => size() === 0, + ); + }); }); it('does not prevent source signal from being GCed if it is short-lived', (t, done) => {