From 157c19a651fc3872d72b63181469b9e9c9b806a4 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 a24b5b556e1a..09b160e9fe5a 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 c481db069d6f..224d65abc70f 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) => {