Commit 7316f81df68 for nodejs
commit 7316f81df68b5e404ca8ecae153d6f8e910235bc
Author: James M Snell <jasnell@gmail.com>
Date: Sat Oct 3 14:42:45 2026 +0000
stream: detach shareSync consumers on strict budget errors
When a shareSync() consumer needed to pull while the buffer was at or
above the budget under the 'strict' policy, it threw ERR_OUT_OF_RANGE
but stayed registered. Neither for...of nor a pullSync() transform
pipeline calls return() when next() throws, so the abandoned consumer
kept its cursor forever, pinned every entry pulled afterwards, and made
the remaining consumers fail with ERR_OUT_OF_RANGE as well.
Detach the consumer before throwing, as the async share() already does
(see 1ad67bc66b2), and document the behavior for both.
The spec notes that a rejected strict pull does not terminate the
consumer's iterator so that it may be retried. That is not safe with
the common iteration patterns, and will be raised with the spec
editors.
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 97de5b5ac92..be9f220590b 100644
--- a/doc/api/stream_iter.md
+++ b/doc/api/stream_iter.md
@@ -1492,6 +1492,13 @@ 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.
+With `'strict'` backpressure, a consumer that needs to pull from the source
+while the buffer is at or above `budget` is rejected with `ERR_OUT_OF_RANGE`
+and detached; further reads from that consumer complete with `{ done: true }`.
+Detaching keeps a consumer that is not retried (for example, one read with
+`for await...of`, which does not call `return()` when a read rejects) from
+holding buffered data and blocking the other consumers.
+
```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 699feeb51c5..7c524655152 100644
--- a/lib/internal/streams/iter/share.js
+++ b/lib/internal/streams/iter/share.js
@@ -615,10 +615,20 @@ class SyncShareImpl {
let dropped = false;
if (self.#bufferedBytes >= self.#options.budget) {
switch (self.#options.backpressure) {
- case 'strict':
- throw new ERR_OUT_OF_RANGE(
+ case 'strict': {
+ const error = new ERR_OUT_OF_RANGE(
'buffered bytes', `< ${self.#options.budget}`,
self.#bufferedBytes);
+ // Detach before throwing, as the async share does. Neither
+ // for...of nor a transform pipeline calls return() when
+ // next() throws, so a consumer left registered here would
+ // keep its cursor forever and wedge the other consumers.
+ state.detached = true;
+ if (self.#deleteConsumer(state)) {
+ self.#tryTrimBuffer();
+ }
+ throw error;
+ }
case 'drop-oldest':
while (self.#bufferedBytes >= self.#options.budget &&
self.#buffer.length > 0) {
diff --git a/test/parallel/test-stream-iter-share-sync.js b/test/parallel/test-stream-iter-share-sync.js
index 6c79a03633a..2d70ea45456 100644
--- a/test/parallel/test-stream-iter-share-sync.js
+++ b/test/parallel/test-stream-iter-share-sync.js
@@ -232,6 +232,56 @@ function testShareSyncRetainsBufferWhenAllConsumersDetach() {
assert.strictEqual(textSync(shared.pull()), 'abc');
}
+function testShareSyncStrictBackpressureDetaches() {
+ for (const transformed of [false, true]) {
+ function* source() {
+ for (let i = 0; i < 10; i++) {
+ yield [new Uint8Array(16384)];
+ }
+ }
+ const shared = shareSync(source(), {
+ budget: 32768,
+ backpressure: 'strict',
+ });
+ const consumer = transformed ?
+ shared.pull((chunks) => chunks) : shared.pull();
+ const fast = consumer[Symbol.iterator]();
+ // This consumer prevents the buffer from being trimmed.
+ const slow = shared.pull()[Symbol.iterator]();
+
+ fast.next();
+ fast.next();
+ assert.throws(() => fast.next(), { code: 'ERR_OUT_OF_RANGE' });
+ // The rejected consumer is detached, as with the async share.
+ assert.strictEqual(shared.consumerCount, 1);
+ assert.strictEqual(fast.next().done, true);
+
+ // The detached consumer no longer pins the buffer, so the remaining
+ // consumer can read the whole source.
+ let count = 0;
+ while (!slow.next().done) count++;
+ assert.strictEqual(count, 10);
+ }
+}
+
+function testShareSyncStrictForOfDoesNotWedgeOthers() {
+ // for...of does not call return() when next() throws. The consumer that
+ // hit the budget must still not keep the other consumers from reading.
+ function* source() {
+ for (let i = 0; i < 20; i++) yield [new Uint8Array(8192)];
+ }
+ const shared = shareSync(source(), { budget: 16384, backpressure: 'strict' });
+ const slow = shared.pull()[Symbol.iterator]();
+ assert.throws(() => {
+ // eslint-disable-next-line no-unused-vars
+ for (const _ of shared.pull()) { /* consume */ }
+ }, { code: 'ERR_OUT_OF_RANGE' });
+ assert.strictEqual(shared.consumerCount, 1);
+ let count = 0;
+ while (!slow.next().done) count++;
+ assert.strictEqual(count, 20);
+}
+
function testShareSyncStringSource() {
const shared = shareSync('hello-sync-share');
const result = textSync(shared.pull());
@@ -251,4 +301,6 @@ Promise.all([
testShareSyncDropNewestUnboundedSource(),
testShareSyncStringSource(),
testShareSyncRetainsBufferWhenAllConsumersDetach(),
+ testShareSyncStrictBackpressureDetaches(),
+ testShareSyncStrictForOfDoesNotWedgeOthers(),
]).then(common.mustCall());