Commit 1d3b65d1014 for nodejs
commit 1d3b65d1014e61beef94f18e2dfe514e47c6f59d
Author: Darius <kxenk@protonmail.com>
Date: Mon Oct 12 09:41:16 2026 +0300
lib: fix AbortSignal.any() nested composite leak
AbortSignal.any() copies the source WeakRefs of a composite input into
the new composite, so nested composites share them. Those WeakRefs were
also used as the unregister tokens for the registrations that prune a
composite's sources once they are collected. When an inner composite
was collected first, its cleanup therefore also unregistered the outer
composite from the same sources. An observed outer composite then
stayed in gcPersistentSignals, together with its listeners, even after
all of its sources were gone and nothing could abort it anymore.
Use each composite's own WeakRef as the unregister token instead, so
collecting one composite only removes its own registrations.
Fixes: https://github.com/nodejs/node/issues/66612
Refs: https://github.com/nodejs/node/pull/62367
Assisted-by: Claude Code
Signed-off-by: journaltraces <kxenk@protonmail.com>
PR-URL: https://github.com/nodejs/node/pull/66617
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
diff --git a/lib/internal/abort_controller.js b/lib/internal/abort_controller.js
index 55be0b6f366..9722484a422 100644
--- a/lib/internal/abort_controller.js
+++ b/lib/internal/abort_controller.js
@@ -86,8 +86,8 @@ function lazyMessageChannel() {
const clearTimeoutRegistry = new SafeFinalizationRegistry(clearTimeout);
const dependantSignalsCleanupRegistry = new SafeFinalizationRegistry(
- ({ sourceSignalRef, dependantSignalRef, sourceSignalsCleanupToken }) => {
- sourceSignalsCleanupRegistry.unregister(sourceSignalsCleanupToken);
+ ({ sourceSignalRef, dependantSignalRef }) => {
+ sourceSignalsCleanupRegistry.unregister(dependantSignalRef);
const sourceSignal = sourceSignalRef.deref();
if (sourceSignal === undefined) {
@@ -180,12 +180,14 @@ function followCompositeSignal(signal) {
dependantSignalsCleanupRegistry.register(signal, {
sourceSignalRef: sourceSignalWeakRef,
dependantSignalRef: resultSignalWeakRef,
- sourceSignalsCleanupToken: sourceSignalWeakRef,
});
+ // Nested composites share their sources' WeakRefs, so use this signal's
+ // own WeakRef as the unregister token. Otherwise collecting an inner
+ // composite would also unregister the outer composite from its sources.
sourceSignalsCleanupRegistry.register(sourceSignal, {
sourceSignalRef: sourceSignalWeakRef,
composedSignalRef: resultSignalWeakRef,
- }, sourceSignalWeakRef);
+ }, resultSignalWeakRef);
}
signal[kFollowing] = true;
diff --git a/test/parallel/test-abortsignal-drop-settled-signals.mjs b/test/parallel/test-abortsignal-drop-settled-signals.mjs
index d4c81a7165d..03e0146722e 100644
--- a/test/parallel/test-abortsignal-drop-settled-signals.mjs
+++ b/test/parallel/test-abortsignal-drop-settled-signals.mjs
@@ -246,6 +246,31 @@ it('drops settled dependent signals when signal is composite', (t, done) => {
}));
});
+it('drops an observed composite of a composite once its sources are collected', async (t) => {
+ let controllers = [new AbortController(), new AbortController()];
+
+ // Only the outer composite is observed, so the inner one is unreachable and
+ // is collected while the sources are still alive.
+ const outerRef = (() => {
+ const outer = AbortSignal.any([
+ controllers[0].signal,
+ AbortSignal.any([controllers[1].signal]),
+ ]);
+ outer.addEventListener('abort', () => {});
+ return new WeakRef(outer);
+ })();
+
+ const kDependantSignals = Object.getOwnPropertySymbols(controllers[1].signal).find(
+ (s) => s.toString() === 'Symbol(kDependantSignals)'
+ );
+ t.assert.strictEqual(controllers[1].signal[kDependantSignals].size, 2);
+
+ await gcUntil('inner composite is collected', () => controllers[1].signal[kDependantSignals].size === 1);
+
+ controllers = null;
+ await gcUntil('outer composite is collected', () => outerRef.deref() === undefined);
+});
+
it('drops settled signals even when there are listeners', (t, done) => {
runWithOrphanListeners(limit, async (signalRefs) => {
await gcUntil('all signals are GCed', () => {