Commit 2693529cc7d for nodejs
commit 2693529cc7dddfef55e97aba09012b303fd6a3e1
Author: James M Snell <jasnell@gmail.com>
Date: Sun Aug 30 17:09:45 2026 +0000
stream: have broadcast clean factory listeners on cancel
Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Opencode
PR-URL: https://github.com/nodejs/node/pull/66030
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js
index 7b3578d3a9c..4c191295a37 100644
--- a/lib/internal/streams/iter/broadcast.js
+++ b/lib/internal/streams/iter/broadcast.js
@@ -88,6 +88,7 @@ const kOnEndDrained = Symbol('kOnEndDrained');
const kOnCancel = Symbol('kOnCancel');
const kPendingWriteRemoved = Symbol('kPendingWriteRemoved');
const kNoBroadcastError = Symbol('kNoBroadcastError');
+const kSetFactorySignal = Symbol('kSetFactorySignal');
function raceEndWithSignal(promise, signal) {
if (!signal) return promise;
@@ -120,6 +121,7 @@ class BroadcastImpl {
#writer = null;
#cachedMinCursor = 0;
#cachedMinCursorConsumers = 0;
+ #abortHandler;
/** Cumulative byte size of buffered entries */
#bufferedBytes = 0;
@@ -134,6 +136,11 @@ class BroadcastImpl {
this.#writer = writer;
}
+ [kSetFactorySignal](signal) {
+ this.#abortHandler = () => this.cancel(signal.reason);
+ onSignalAbort(signal, this.#abortHandler);
+ }
+
get backpressurePolicy() {
return this.#options.backpressure;
}
@@ -317,6 +324,7 @@ class BroadcastImpl {
this.#consumers.clear();
this.#waiters.clear();
this.#cachedMinCursorConsumers = 0;
+ this.#cleanupFactorySignal();
const onCancel = this[kOnCancel];
this[kOnCancel] = null;
onCancel?.(reason);
@@ -428,6 +436,7 @@ class BroadcastImpl {
this.#consumers.clear();
this.#waiters.clear();
this.#cachedMinCursorConsumers = 0;
+ this.#cleanupFactorySignal();
}
/**
@@ -447,10 +456,18 @@ class BroadcastImpl {
#notifyEndDrained() {
if (this.#ended && this.#consumers.size === 0) {
+ this.#cleanupFactorySignal();
this[kOnEndDrained]?.();
}
}
+ #cleanupFactorySignal() {
+ if (this.#abortHandler !== undefined) {
+ this.#options.signal.removeEventListener('abort', this.#abortHandler);
+ this.#abortHandler = undefined;
+ }
+ }
+
#recomputeMinCursor() {
const { minCursor, minCursorConsumers } = getMinCursor(
this.#consumers, this.#bufferStart + this.#buffer.length);
@@ -857,10 +874,6 @@ function wireBroadcastWriteSignal(entry, signal, resolve, reject, self) {
signal.addEventListener('abort', onAbort, { __proto__: null, once: true });
}
-function onBroadcastCancel(broadcastImpl, signal) {
- onSignalAbort(signal, () => broadcastImpl.cancel(signal.reason));
-}
-
// =============================================================================
// Public API
// =============================================================================
@@ -894,7 +907,7 @@ function broadcast(options = { __proto__: null }) {
broadcastImpl.setWriter(writer);
if (signal) {
- onBroadcastCancel(broadcastImpl, signal);
+ broadcastImpl[kSetFactorySignal](signal);
}
return { __proto__: null, writer, broadcast: broadcastImpl };
diff --git a/lib/internal/streams/iter/duplex.js b/lib/internal/streams/iter/duplex.js
index 674ef81a53c..95a117d8141 100644
--- a/lib/internal/streams/iter/duplex.js
+++ b/lib/internal/streams/iter/duplex.js
@@ -45,8 +45,10 @@ function duplex(options = { __proto__: null }) {
backpressure: b?.backpressure ?? backpressure,
});
- const channelA = createDuplexChannel(aWriter, aReadable);
- const channelB = createDuplexChannel(bWriter, bReadable);
+ let cleanupSignal;
+ const onClose = () => cleanupSignal?.();
+ const channelA = createDuplexChannel(aWriter, aReadable, onClose);
+ const channelB = createDuplexChannel(bWriter, bReadable, onClose);
// Signal handler: fail both writers with the abort reason so consumers
// see the error. This is an error-path shutdown, not a clean close.
@@ -55,6 +57,11 @@ function duplex(options = { __proto__: null }) {
const reason = signal.reason;
aWriter.fail(reason);
bWriter.fail(reason);
+ cleanupSignal?.();
+ };
+ cleanupSignal = () => {
+ signal.removeEventListener('abort', abortBoth);
+ cleanupSignal = undefined;
};
if (signal.aborted) {
abortBoth();
@@ -67,7 +74,7 @@ function duplex(options = { __proto__: null }) {
return [channelA, channelB];
}
-function createDuplexChannel(writer, readable) {
+function createDuplexChannel(writer, readable, onClose) {
// A push readable has one shared consumer state. Keeping an iterator from
// creation lets close() terminate that state even if no caller has iterated.
const closeIterator = readable[SymbolAsyncIterator]();
@@ -78,7 +85,7 @@ function createDuplexChannel(writer, readable) {
get writer() { return writer; },
get readable() { return readable; },
close() {
- closePromise ??= closeDuplexChannel(writer, closeIterator);
+ closePromise ??= closeDuplexChannel(writer, closeIterator, onClose);
return closePromise;
},
[SymbolAsyncDispose]() {
@@ -87,19 +94,23 @@ function createDuplexChannel(writer, readable) {
};
}
-async function closeDuplexChannel(writer, closeIterator) {
- const result = writer.endSync();
- const endPromise = result < 0 ? writer.end() : undefined;
- const returnPromise = closeIterator.return();
+async function closeDuplexChannel(writer, closeIterator, onClose) {
+ try {
+ const result = writer.endSync();
+ const endPromise = result < 0 ? writer.end() : undefined;
+ const returnPromise = closeIterator.return();
- if (endPromise !== undefined) {
- try {
- await SafePromiseAllReturnVoid([endPromise, returnPromise]);
- } catch (error) {
- if (!isConsumerReturnError(error)) throw error;
+ if (endPromise !== undefined) {
+ try {
+ await SafePromiseAllReturnVoid([endPromise, returnPromise]);
+ } catch (error) {
+ if (!isConsumerReturnError(error)) throw error;
+ }
+ } else {
+ await returnPromise;
}
- } else {
- await returnPromise;
+ } finally {
+ onClose();
}
}
diff --git a/lib/internal/streams/iter/share.js b/lib/internal/streams/iter/share.js
index e860b7119cb..943d83ca755 100644
--- a/lib/internal/streams/iter/share.js
+++ b/lib/internal/streams/iter/share.js
@@ -71,6 +71,7 @@ const {
const kNoShareError = Symbol('kNoShareError');
const kShareCancelled = Symbol('kShareCancelled');
+const kSetFactorySignal = Symbol('kSetFactorySignal');
class ShareImpl {
#source;
@@ -89,6 +90,7 @@ class ShareImpl {
#cancelError = kNoShareError;
#cachedMinCursor = 0;
#cachedMinCursorConsumers = 0;
+ #abortHandler;
/** Cumulative byte size of buffered entries */
#bufferedBytes = 0;
@@ -104,6 +106,11 @@ class ShareImpl {
return this.#consumers.size;
}
+ [kSetFactorySignal](signal) {
+ this.#abortHandler = () => this.cancel(signal.reason);
+ onSignalAbort(signal, this.#abortHandler);
+ }
+
pull(...args) {
const parsed = parsePullArgs(args);
const { transforms } = parsed;
@@ -303,6 +310,7 @@ class ShareImpl {
this.#consumers.clear();
this.#buffer.clear();
this.#bufferedBytes = 0;
+ this.#cleanupFactorySignal();
for (let i = 0; i < this.#pullWaiters.length; i++) {
this.#pullWaiters[i]();
@@ -424,6 +432,7 @@ class ShareImpl {
this.#sourceError = error;
this.#sourceExhausted = true;
} finally {
+ if (this.#sourceExhausted) this.#cleanupFactorySignal();
this.#pulling = false;
for (let i = 0; i < this.#pullWaiters.length; i++) {
this.#pullWaiters[i]();
@@ -470,6 +479,13 @@ class ShareImpl {
this.#cachedMinCursorConsumers = minCursorConsumers;
}
+ #cleanupFactorySignal() {
+ if (this.#abortHandler !== undefined) {
+ this.#options.signal.removeEventListener('abort', this.#abortHandler);
+ this.#abortHandler = undefined;
+ }
+ }
+
#deleteConsumerFromMin(consumer) {
if (consumer.cursor === this.#cachedMinCursor) {
this.#cachedMinCursorConsumers--;
@@ -768,10 +784,6 @@ class SyncShareImpl {
}
}
-function onShareCancel(shareImpl, signal) {
- onSignalAbort(signal, () => shareImpl.cancel(signal.reason));
-}
-
// =============================================================================
// Public API
// =============================================================================
@@ -800,7 +812,7 @@ function share(source, options = { __proto__: null }) {
const shareImpl = new ShareImpl(normalized, opts);
if (signal) {
- onShareCancel(shareImpl, signal);
+ shareImpl[kSetFactorySignal](signal);
}
return shareImpl;
diff --git a/test/parallel/test-stream-iter-factory-signal.js b/test/parallel/test-stream-iter-factory-signal.js
new file mode 100644
index 00000000000..7d0ac92b125
--- /dev/null
+++ b/test/parallel/test-stream-iter-factory-signal.js
@@ -0,0 +1,84 @@
+// Flags: --experimental-stream-iter
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const { getEventListeners } = require('events');
+const {
+ broadcast,
+ duplex,
+ push,
+ share,
+ text,
+} = require('stream/iter');
+
+function abortListenerCount(signal) {
+ return getEventListeners(signal, 'abort').length;
+}
+
+async function testPushSignalLifetime() {
+ const controller = new AbortController();
+ const { writer, readable } = push({ signal: controller.signal });
+ const iterator = readable[Symbol.asyncIterator]();
+
+ assert.strictEqual(abortListenerCount(controller.signal), 1);
+ assert.strictEqual(writer.writeSync('x'), true);
+ const ending = writer.end();
+ assert.strictEqual(abortListenerCount(controller.signal), 1);
+
+ assert.strictEqual((await iterator.next()).done, false);
+ assert.strictEqual((await iterator.next()).done, true);
+ assert.strictEqual(await ending, 1);
+ assert.strictEqual(abortListenerCount(controller.signal), 0);
+}
+
+async function testBroadcastSignalLifetime() {
+ const controller = new AbortController();
+ const { writer, broadcast: shared } = broadcast({
+ signal: controller.signal,
+ });
+ const iterator = shared.push()[Symbol.asyncIterator]();
+
+ assert.strictEqual(abortListenerCount(controller.signal), 1);
+ assert.strictEqual(writer.writeSync('x'), true);
+ const ending = writer.end();
+ assert.strictEqual(abortListenerCount(controller.signal), 1);
+
+ assert.strictEqual((await iterator.next()).done, false);
+ assert.strictEqual((await iterator.next()).done, true);
+ assert.strictEqual(await ending, 1);
+ assert.strictEqual(abortListenerCount(controller.signal), 0);
+}
+
+async function testShareSignalLifetime() {
+ const controller = new AbortController();
+ const shared = share('x', { signal: controller.signal });
+ const iterator = shared.pull()[Symbol.asyncIterator]();
+
+ assert.strictEqual(abortListenerCount(controller.signal), 1);
+ assert.strictEqual((await iterator.next()).done, false);
+ assert.strictEqual(abortListenerCount(controller.signal), 1);
+ assert.strictEqual((await iterator.next()).done, true);
+ assert.strictEqual(abortListenerCount(controller.signal), 0);
+}
+
+async function testDuplexSignalLifetime() {
+ const controller = new AbortController();
+ const [channelA, channelB] = duplex({ signal: controller.signal });
+
+ assert.strictEqual(abortListenerCount(controller.signal), 1);
+ await channelA.writer.write('x');
+ const closing = channelA.close();
+ assert.strictEqual(abortListenerCount(controller.signal), 1);
+
+ assert.strictEqual(await text(channelB.readable), 'x');
+ await closing;
+ assert.strictEqual(abortListenerCount(controller.signal), 0);
+}
+
+Promise.all([
+ testPushSignalLifetime(),
+ testBroadcastSignalLifetime(),
+ testShareSignalLifetime(),
+ testDuplexSignalLifetime(),
+]).then(common.mustCall());