From d0e9e7d501b17cc6330ec508f0071055eef25b2c Mon Sep 17 00:00:00 2001 From: inoway46 Date: Sun, 13 Sep 2026 18:52:08 +0900 Subject: [PATCH] lib: fix AbortSignal.any() abort propagation Follow composite signal sources at construction so abort state is propagated even without listeners, before sources can be collected. Keep the existing weak-reference finalization and timeout cleanup. Cover source collection before each state accessor, including nested composites, and cleanup of unreachable listener-less dependents. Fixes: https://github.com/nodejs/node/issues/65995 Refs: https://github.com/nodejs/node/issues/62363 Assisted-by: Codex Signed-off-by: inoway46 --- lib/internal/abort_controller.js | 4 +++ .../test-abortsignal-any-source-gc.mjs | 31 +++++++++++++++++ .../test-abortsignal-drop-settled-signals.mjs | 34 ++++++++++++++++--- 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 test/parallel/test-abortsignal-any-source-gc.mjs diff --git a/lib/internal/abort_controller.js b/lib/internal/abort_controller.js index ef30d9619749..55be0b6f3669 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 000000000000..06fd0c0e29f4 --- /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 224d65abc70f..d4c81a7165d4 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 = () => {};