Commit 12e34ca588d for nodejs
commit 12e34ca588d8dcda891d4fefe2d807d3a124f495
Author: James M Snell <jasnell@gmail.com>
Date: Sat Oct 3 15:08:04 2026 +0000
stream: accept explicit stream/iter budgets below 16384
push(), duplex(), broadcast(), share() and shareSync() rejected an
explicit budget below 16384 bytes with ERR_OUT_OF_RANGE. The spec
(push() step 3, broadcast() step 1 and share() step 2) only requires
the implementation-defined default to be at least 16384 bytes; an
explicit budget is used as given. Small budgets are also useful in
tests and in memory-constrained code.
Accept any explicit budget of at least 1 byte, and validate it in one
place. Defaults are unchanged. The validation tests are updated to the
new lower bound; note that WebIDL conversion truncates fractions, so
1.5 is now a valid budget of 1 and 0.5 is used to exercise the
rejection instead.
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 ad52645bfcb..fb4a26a6cd6 100644
--- a/doc/api/stream_iter.md
+++ b/doc/api/stream_iter.md
@@ -815,7 +815,7 @@ added:
readable side.
* `options` {Object}
* `budget` {number} Maximum number of buffered bytes before
- backpressure is applied. Must be >= 16384.
+ backpressure is applied. Must be a positive integer.
**Default:** `16384`.
* `backpressure` {string} Backpressure policy: `'strict'`, `'unbounded'`,
`'drop-oldest'`, or `'drop-newest'`. **Default:** `'strict'`.
@@ -881,7 +881,7 @@ added:
* `options` {Object}
* `budget` {number} Buffer size in bytes for both directions.
- Must be >= 16384. **Default:** `16384`.
+ Must be a positive integer. **Default:** `16384`.
* `backpressure` {string} Policy for both directions.
**Default:** `'strict'`.
* `signal` {AbortSignal} Cancellation signal for both channels.
@@ -1365,7 +1365,7 @@ added:
-->
* `options` {Object}
- * `budget` {number} Buffer size in bytes. Must be >= 16384.
+ * `budget` {number} Buffer size in bytes. Must be a positive integer.
**Default:** `65536`.
* `backpressure` {string} `'strict'`, `'unbounded'`, `'drop-oldest'`, or
`'drop-newest'`. **Default:** `'strict'`.
@@ -1479,7 +1479,7 @@ added:
* `source` {AsyncIterable} The source to share.
* `options` {Object}
- * `budget` {number} Buffer size in bytes. Must be >= 16384.
+ * `budget` {number} Buffer size in bytes. Must be a positive integer.
**Default:** `65536`.
* `backpressure` {string} `'strict'`, `'unbounded'`, `'drop-oldest'`, or
`'drop-newest'`. **Default:** `'strict'`.
@@ -1607,7 +1607,7 @@ added:
* `source` {Iterable} The sync source to share.
* `options` {Object}
- * `budget` {number} Must be >= 16384.
+ * `budget` {number} Must be a positive integer.
**Default:** `65536`.
* `backpressure` {string} `'strict'` or `'drop-oldest'`.
**Default:** `'strict'`.
diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js
index e32f8aa544d..cdf25021bbc 100644
--- a/lib/internal/streams/iter/broadcast.js
+++ b/lib/internal/streams/iter/broadcast.js
@@ -40,9 +40,6 @@ const {
ERR_INVALID_STATE,
},
} = require('internal/errors');
-const {
- validateInteger,
-} = require('internal/validators');
const {
broadcastProtocol,
@@ -72,6 +69,7 @@ const {
toWriterUint8Array,
validateBatchEntry,
yieldAbortable,
+ validateBudget,
} = require('internal/streams/iter/utils');
const {
converters,
@@ -895,7 +893,7 @@ function broadcast(options = { __proto__: null }) {
backpressure = 'strict',
signal,
} = options;
- validateInteger(budget, 'options.budget', 16384);
+ validateBudget(budget);
const opts = {
__proto__: null,
diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js
index 31b437c85b7..764a3afac92 100644
--- a/lib/internal/streams/iter/push.js
+++ b/lib/internal/streams/iter/push.js
@@ -22,9 +22,6 @@ const {
ERR_INVALID_STATE,
},
} = require('internal/errors');
-const {
- validateInteger,
-} = require('internal/validators');
const {
drainableProtocol,
@@ -40,6 +37,7 @@ const {
getWriterSignal,
parsePullArgs,
validateBatchEntry,
+ validateBudget,
} = require('internal/streams/iter/utils');
const {
converters,
@@ -132,7 +130,7 @@ class PushQueue {
backpressure = 'strict',
signal,
} = options;
- validateInteger(budget, 'options.budget', 16384);
+ validateBudget(budget);
this.#budget = budget;
this.#backpressure = backpressure;
this.#signal = signal;
diff --git a/lib/internal/streams/iter/share.js b/lib/internal/streams/iter/share.js
index 17dabf5da03..6e9881b682a 100644
--- a/lib/internal/streams/iter/share.js
+++ b/lib/internal/streams/iter/share.js
@@ -46,6 +46,7 @@ const {
parsePullArgs,
splitBatchEntry,
validateBatchEntry,
+ validateBudget,
} = require('internal/streams/iter/utils');
const {
converters,
@@ -64,9 +65,6 @@ const {
ERR_OUT_OF_RANGE,
},
} = require('internal/errors');
-const {
- validateInteger,
-} = require('internal/validators');
const { markPromiseAsHandled } = internalBinding('util');
@@ -865,7 +863,7 @@ function share(source, options = { __proto__: null }) {
backpressure = 'strict',
signal,
} = options;
- validateInteger(budget, 'options.budget', 16384);
+ validateBudget(budget);
const opts = {
__proto__: null,
@@ -894,7 +892,7 @@ function shareSync(source, options = { __proto__: null }) {
budget = kMultiConsumerDefaultBudget,
backpressure = 'strict',
} = options;
- validateInteger(budget, 'options.budget', 16384);
+ validateBudget(budget);
// A synchronous consumer can neither wait for the slowest consumer to
// release budget ('unbounded') nor keep pulling and discarding until it
// does ('drop-newest'): the slowest consumer cannot advance while the
diff --git a/lib/internal/streams/iter/utils.js b/lib/internal/streams/iter/utils.js
index 99e5c5cd093..2f72a78c0d2 100644
--- a/lib/internal/streams/iter/utils.js
+++ b/lib/internal/streams/iter/utils.js
@@ -35,6 +35,7 @@ const {
const { isSharedArrayBuffer, isUint8Array } = require('internal/util/types');
const {
+ validateInteger,
validateOneOf,
} = require('internal/validators');
const {
@@ -236,6 +237,15 @@ function validateByteView(snapshot) {
return value;
}
+/**
+ * Validate an explicit `budget` option. The spec only requires the default
+ * budget to be at least 16384 bytes; any positive explicit budget is valid.
+ * @param {unknown} budget
+ */
+function validateBudget(budget) {
+ validateInteger(budget, 'options.budget', 1);
+}
+
/**
* Append a chunk to `chunks` for later concatenation, and the information
* needed to detect that it was resized or detached in the meantime to
@@ -550,6 +560,7 @@ module.exports = {
toWriterUint8Array,
validateBackpressure,
validateBatchEntry,
+ validateBudget,
validateRecordedChunks,
validateByteView,
yieldAbortable,
diff --git a/test/parallel/test-stream-iter-push-backpressure.js b/test/parallel/test-stream-iter-push-backpressure.js
index 9fb3325f342..58ba2d331dc 100644
--- a/test/parallel/test-stream-iter-push-backpressure.js
+++ b/test/parallel/test-stream-iter-push-backpressure.js
@@ -150,6 +150,22 @@ async function testStrictPendingQueueOverflow() {
await iter.return();
}
+async function testSmallBudget() {
+ // An explicit budget below the 16384-byte default is honored.
+ const { writer, readable } = push({ budget: 4 });
+ assert.strictEqual(writer.writeSync('abc'), true);
+ assert.strictEqual(writer.canWrite, true);
+ // The buffer may overshoot the budget by one write.
+ assert.strictEqual(writer.writeSync('defgh'), true);
+ assert.strictEqual(writer.canWrite, false);
+ assert.strictEqual(writer.writeSync('i'), false);
+ const write = writer.write('ij');
+ const result = text(readable);
+ await write;
+ writer.endSync();
+ assert.strictEqual(await result, 'abcdefghij');
+}
+
Promise.all([
testStrictBackpressure(),
testDropOldest(),
@@ -157,4 +173,5 @@ Promise.all([
testBlockBackpressure(),
testBlockWriteSyncDoesNotEnqueue(),
testStrictPendingQueueOverflow(),
+ testSmallBudget(),
]).then(common.mustCall());
diff --git a/test/parallel/test-stream-iter-validation.js b/test/parallel/test-stream-iter-validation.js
index b130a92ae3d..16ff12c060e 100644
--- a/test/parallel/test-stream-iter-validation.js
+++ b/test/parallel/test-stream-iter-validation.js
@@ -21,15 +21,18 @@ const {
// push() validation
// =============================================================================
-// Budget must be integer >= 16384
+// Budget must be an integer >= 1
assert.throws(() => push({ budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' });
-assert.throws(() => push({ budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' });
-// Values < 16384 are rejected
+// WebIDL conversion truncates fractions: 0.5 becomes 0, 1.5 becomes 1.
+assert.throws(() => push({ budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' });
+assert.strictEqual(push({ budget: 1.5 }).writer.canWrite, true);
+// Values < 1 are rejected
assert.throws(() => push({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => push({ budget: -1 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => push({ budget: -100 }), { code: 'ERR_OUT_OF_RANGE' });
-assert.throws(() => push({ budget: 16383 }), { code: 'ERR_OUT_OF_RANGE' });
-// 16384 is the minimum accepted value
+// Only the default must be at least 16384; smaller explicit budgets are valid
+assert.strictEqual(push({ budget: 1 }).writer.canWrite, true);
+assert.strictEqual(push({ budget: 16383 }).writer.canWrite, true);
assert.strictEqual(push({ budget: 16384 }).writer.canWrite, true);
// MAX_SAFE_INTEGER is accepted
assert.strictEqual(push({ budget: Number.MAX_SAFE_INTEGER }).writer.canWrite,
@@ -87,11 +90,11 @@ assert.throws(() => duplex({ b: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
// Budget validation (cascades through to push())
assert.throws(() => duplex({ budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' });
-assert.throws(() => duplex({ budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' });
+assert.throws(() => duplex({ budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => duplex({ budget: Number.MAX_SAFE_INTEGER + 1 }),
{ code: 'ERR_OUT_OF_RANGE' });
-// Values < 16384 are rejected (both directions)
+// Values < 1 are rejected (both directions)
assert.throws(() => duplex({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
// MAX_SAFE_INTEGER is accepted
{
@@ -129,17 +132,16 @@ assert.throws(() => pullSync(fromSync('a'), 42), { code: 'ERR_INVALID_ARG_TYPE'
// =============================================================================
assert.throws(() => broadcast({ budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' });
-assert.throws(() => broadcast({ budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' });
+assert.throws(() => broadcast({ budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => broadcast({ budget: Number.MAX_SAFE_INTEGER + 1 }),
{ code: 'ERR_OUT_OF_RANGE' });
-// Values < 16384 are rejected
+// Values < 1 are rejected
assert.throws(() => broadcast({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => broadcast({ budget: -1 }), { code: 'ERR_OUT_OF_RANGE' });
-assert.throws(() => broadcast({ budget: 16383 }), { code: 'ERR_OUT_OF_RANGE' });
-// 16384 is the minimum accepted value
+// Small explicit budgets are accepted
{
- const bc = broadcast({ budget: 16384 });
+ const bc = broadcast({ budget: 1 });
bc.broadcast.push();
assert.strictEqual(bc.writer.canWrite, true);
bc.writer.endSync();
@@ -235,7 +237,7 @@ assert.throws(
assert.throws(() => share(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => share(from('a'), { budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' });
-assert.throws(() => share(from('a'), { budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' });
+assert.throws(() => share(from('a'), { budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => share(from('a'), { budget: Number.MAX_SAFE_INTEGER + 1 }),
{ code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => share(from('a'), { signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' });
@@ -250,28 +252,28 @@ assert.throws(() => share(from('a'), { backpressure: 'bad' }), { code: 'ERR_INVA
assert.strictEqual(shared.consumerCount, 0);
}
-// share() values < 16384 are rejected
+// share() values < 1 are rejected
assert.throws(() => share(from('a'), { budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => share(from('a'), { budget: -1 }), { code: 'ERR_OUT_OF_RANGE' });
-// 16384 is the minimum, MAX_SAFE_INTEGER is accepted
-share(from('a'), { budget: 16384 }).cancel();
+// 1 is the minimum, MAX_SAFE_INTEGER is accepted
+share(from('a'), { budget: 1 }).cancel();
share(from('a'), { budget: Number.MAX_SAFE_INTEGER }).cancel();
assert.throws(() => shareSync(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => shareSync(fromSync('a'), { budget: 'bad' }),
{ code: 'ERR_OUT_OF_RANGE' });
-assert.throws(() => shareSync(fromSync('a'), { budget: 1.5 }),
+assert.throws(() => shareSync(fromSync('a'), { budget: 0.5 }),
{ code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => shareSync(fromSync('a'), { budget: Number.MAX_SAFE_INTEGER + 1 }),
{ code: 'ERR_OUT_OF_RANGE' });
-// shareSync() values < 16384 are rejected
+// shareSync() values < 1 are rejected
assert.throws(() => shareSync(fromSync('a'), { budget: 0 }),
{ code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => shareSync(fromSync('a'), { budget: -1 }),
{ code: 'ERR_OUT_OF_RANGE' });
-// 16384 is the minimum, MAX_SAFE_INTEGER is accepted
-shareSync(fromSync('a'), { budget: 16384 }).cancel();
+// 1 is the minimum, MAX_SAFE_INTEGER is accepted
+shareSync(fromSync('a'), { budget: 1 }).cancel();
shareSync(fromSync('a'), { budget: Number.MAX_SAFE_INTEGER }).cancel();
// Share.from / SyncShare.fromSync reject non-iterable