Commit afb09c67655 for nodejs
commit afb09c67655d36f47852e72f7162269794efa169
Author: James M Snell <jasnell@gmail.com>
Date: Sat Oct 3 14:41:39 2026 +0000
stream: keep share buffer when all consumers detach
When the last consumer of a share() or shareSync() detached, the min
cursor fell back to the end of the buffer, so every buffered entry was
trimmed, including entries that the detaching consumer had not read.
The source had already produced that data, so consumers that attached
later silently skipped it.
Keep the buffer while there are no consumers, as broadcast() already
does, so late-joining consumers start at the oldest entry still in the
buffer. Document that the source stays open until the share is
cancelled or disposed.
Assisted-by: OpenCode
Signed-off-by: James M Snell <jasnell@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66483
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md
index e8cc8c63632..97de5b5ac92 100644
--- a/doc/api/stream_iter.md
+++ b/doc/api/stream_iter.md
@@ -1486,6 +1486,12 @@ Create a pull-model multi-consumer shared stream. Unlike `broadcast()`, the
source is only read when a consumer pulls. Multiple consumers share a single
buffer.
+A consumer created with `share.pull()` starts reading at the oldest entry still
+in the buffer. Entries are released once every consumer has read them. When
+every consumer has detached, the buffered data is kept for consumers that
+attach later, and the source is not closed. Call `share.cancel()` (or dispose
+the share) to release the source once it is no longer needed.
+
```mjs
import { from, share, text } from 'node:stream/iter';
diff --git a/lib/internal/streams/iter/share.js b/lib/internal/streams/iter/share.js
index b53da4608b2..699feeb51c5 100644
--- a/lib/internal/streams/iter/share.js
+++ b/lib/internal/streams/iter/share.js
@@ -448,6 +448,11 @@ class ShareImpl {
}
#tryTrimBuffer() {
+ // Retain buffered data for consumers that attach while none are active.
+ // Without this, the last consumer detaching would discard entries that
+ // a late-joining consumer has not read and cannot get back from the
+ // source.
+ if (this.#consumers.size === 0) return;
if (this.#cachedMinCursorConsumers === 0) {
this.#recomputeMinCursor();
}
@@ -744,6 +749,11 @@ class SyncShareImpl {
}
#tryTrimBuffer() {
+ // Retain buffered data for consumers that attach while none are active.
+ // Without this, the last consumer detaching would discard entries that
+ // a late-joining consumer has not read and cannot get back from the
+ // source.
+ if (this.#consumers.size === 0) return;
if (this.#cachedMinCursorConsumers === 0) {
this.#recomputeMinCursor();
}
diff --git a/test/parallel/test-stream-iter-share-async.js b/test/parallel/test-stream-iter-share-async.js
index 08fcd0bd338..29de8e8a141 100644
--- a/test/parallel/test-stream-iter-share-async.js
+++ b/test/parallel/test-stream-iter-share-async.js
@@ -379,6 +379,27 @@ async function testShareLateJoiningConsumer() {
assert.strictEqual(data2, '');
}
+async function testShareRetainsBufferWhenAllConsumersDetach() {
+ // Data that a consumer had not read yet must stay available to consumers
+ // that attach after every previous consumer has detached.
+ const enc = new TextEncoder();
+ async function* gen() {
+ yield [enc.encode('a')];
+ yield [enc.encode('b')];
+ yield [enc.encode('c')];
+ }
+ const shared = share(gen(), { budget: 16384 });
+ const c1 = shared.pull()[Symbol.asyncIterator]();
+ const c2 = shared.pull()[Symbol.asyncIterator]();
+ assert.deepStrictEqual((await c1.next()).value, [enc.encode('a')]);
+ // c2 has not read 'a' yet; it is the last consumer to detach.
+ await c1.return();
+ await c2.return();
+ assert.strictEqual(shared.consumerCount, 0);
+
+ assert.strictEqual(await text(shared.pull()), 'abc');
+}
+
async function testShareConsumerBreak() {
// Verify that a consumer breaking mid-iteration detaches properly
const enc = new TextEncoder();
@@ -473,6 +494,7 @@ Promise.all([
testShareSourceError(),
testShareSourceErrorFollowsBufferedData(),
testShareLateJoiningConsumer(),
+ testShareRetainsBufferWhenAllConsumersDetach(),
testShareConsumerBreak(),
testShareMultipleConsumersConcurrentPull(),
testShareConsumerConcurrentNextCalls(),
diff --git a/test/parallel/test-stream-iter-share-sync.js b/test/parallel/test-stream-iter-share-sync.js
index fd0184df9bc..6c79a03633a 100644
--- a/test/parallel/test-stream-iter-share-sync.js
+++ b/test/parallel/test-stream-iter-share-sync.js
@@ -212,6 +212,26 @@ function testShareSyncDropNewestUnboundedSource() {
}
// shareSync() accepts string source directly (normalized via fromSync())
+function testShareSyncRetainsBufferWhenAllConsumersDetach() {
+ // Data that a consumer had not read yet must stay available to consumers
+ // that attach after every previous consumer has detached.
+ const enc = new TextEncoder();
+ function* gen() {
+ yield [enc.encode('a')];
+ yield [enc.encode('b')];
+ yield [enc.encode('c')];
+ }
+ const shared = shareSync(gen(), { budget: 16384 });
+ const c1 = shared.pull()[Symbol.iterator]();
+ const c2 = shared.pull()[Symbol.iterator]();
+ assert.deepStrictEqual(c1.next().value, [enc.encode('a')]);
+ c1.return();
+ c2.return();
+ assert.strictEqual(shared.consumerCount, 0);
+
+ assert.strictEqual(textSync(shared.pull()), 'abc');
+}
+
function testShareSyncStringSource() {
const shared = shareSync('hello-sync-share');
const result = textSync(shared.pull());
@@ -230,4 +250,5 @@ Promise.all([
testShareSyncDropNewest(),
testShareSyncDropNewestUnboundedSource(),
testShareSyncStringSource(),
+ testShareSyncRetainsBufferWhenAllConsumersDetach(),
]).then(common.mustCall());