diff --git a/lib/internal/abort_controller.js b/lib/internal/abort_controller.js index ef30d961974..55be0b6f366 100644 --- a/lib/internal/abort_controller.js +++ b/lib/internal/abort_controller.js @@ -381,6 +381,10 @@ class AbortSignal extends EventTarget { resultSignal[kTimeout] = true; } + // Preserve abort state even if sources are collected before this signal is + // observed. Following uses weak references so unused signals can be collected. + followCompositeSignal(resultSignal); + return resultSignal; } diff --git a/test/parallel/test-abortsignal-any-source-gc.mjs b/test/parallel/test-abortsignal-any-source-gc.mjs new file mode 100644 index 00000000000..06fd0c0e29f --- /dev/null +++ b/test/parallel/test-abortsignal-any-source-gc.mjs @@ -0,0 +1,31 @@ +// Flags: --expose-gc + +import '../common/index.mjs'; +import { gcUntil } from '../common/gc.js'; +import assert from 'node:assert/strict'; +import { it } from 'node:test'; + +for (const nested of [false, true]) { + for (const accessor of ['aborted', 'reason', 'throwIfAborted']) { + it(`preserves ${accessor} after source GC (nested: ${nested})`, async () => { + let controller = new AbortController(); + const sourceRef = new WeakRef(controller.signal); + let signal = AbortSignal.any([controller.signal]); + if (nested) signal = AbortSignal.any([signal]); + const reason = { message: 'stop' }; + + controller.abort(reason); + controller = null; + + // Do not observe the composite or attach a listener before source GC. + await gcUntil('source signal is collected', () => sourceRef.deref() === undefined); + + // Exercise each entry point before any other accessor can refresh state. + if (accessor === 'aborted') assert.strictEqual(signal.aborted, true); + if (accessor === 'reason') assert.strictEqual(signal.reason, reason); + assert.throws(() => signal.throwIfAborted(), (err) => err === reason); + assert.strictEqual(signal.aborted, true); + assert.strictEqual(signal.reason, reason); + }); + } +} diff --git a/test/parallel/test-abortsignal-drop-settled-signals.mjs b/test/parallel/test-abortsignal-drop-settled-signals.mjs index 224d65abc70..d4c81a7165d 100644 --- a/test/parallel/test-abortsignal-drop-settled-signals.mjs +++ b/test/parallel/test-abortsignal-drop-settled-signals.mjs @@ -122,16 +122,22 @@ describe('when there is a long-lived signal', () => { }, true); }); - it('does not keep retained dependent signals without listeners', (t, done) => { + it('propagates abort to retained dependent signals without listeners', (t, done) => { const ac = new AbortController(); const retainedSignals = []; - const kDependantSignals = Object.getOwnPropertySymbols(ac.signal).find( - (s) => s.toString() === 'Symbol(kDependantSignals)' - ); function run(iteration) { if (iteration > limit) { - t.assert.strictEqual(ac.signal[kDependantSignals]?.size ?? 0, 0); + const kDependantSignals = Object.getOwnPropertySymbols(ac.signal).find( + (s) => s.toString() === 'Symbol(kDependantSignals)' + ); + t.assert.strictEqual(ac.signal[kDependantSignals].size, limit); + ac.abort('stop'); + for (const signal of retainedSignals) { + t.assert.strictEqual(signal.aborted, true); + t.assert.strictEqual(signal.reason, 'stop'); + t.assert.throws(() => signal.throwIfAborted(), (err) => err === 'stop'); + } done(); return; } @@ -143,6 +149,24 @@ describe('when there is a long-lived signal', () => { run(1); }); + it('drops unreachable dependent signals without listeners', async () => { + const ac = new AbortController(); + const size = () => { + const sym = Object.getOwnPropertySymbols(ac.signal).find( + (s) => s.toString() === 'Symbol(kDependantSignals)' + ); + return ac.signal[sym]?.size ?? 0; + }; + + // Reuse a long-lived source across batches to catch accumulating WeakRefs. + for (let batch = 0; batch < 3; batch++) { + for (let i = 0; i < limit; i++) { + AbortSignal.any([ac.signal]); + } + await gcUntil('unreachable dependents are dropped', () => size() === 0); + } + }); + it('drops observed dependent signals once they are transitively aborted', async () => { const longLived = new AbortController(); const handler = () => {};