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) => {