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