From 6352f2623830397492d4ba7e46c3bd75cad0776b Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 29 Aug 2026 23:19:49 +0000 Subject: [PATCH 01/23] stream: fixup cancelation handling in pull() Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/pull.js | 34 ++++++++++++++++++-- test/parallel/test-stream-iter-pull-async.js | 30 +++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index b6c2d9849c42..aef7a7cacd58 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -34,7 +34,10 @@ const { isPromise, isUint8Array, } = require('internal/util/types'); -const { AbortController } = require('internal/abort_controller'); +const { + AbortController, + AbortSignal, +} = require('internal/abort_controller'); const { arrayBufferViewToUint8Array, @@ -862,8 +865,33 @@ function pull(source, ...args) { return { __proto__: null, - async *[SymbolAsyncIterator]() { - yield* createAsyncPipeline(from(source), transforms, signal); + [SymbolAsyncIterator]() { + const controller = new AbortController(); + const iteratorSignal = signal === undefined ? + controller.signal : AbortSignal.any([signal, controller.signal]); + + async function* pipeline() { + yield* createAsyncPipeline(from(source), transforms, iteratorSignal); + } + const iterator = pipeline(); + + return { + __proto__: null, + next(value) { + return iterator.next(value); + }, + return(value) { + controller.abort(lazyDOMException('Aborted', 'AbortError')); + return iterator.return(value); + }, + throw(error) { + controller.abort(error); + return iterator.throw(error); + }, + [SymbolAsyncIterator]() { + return this; + }, + }; }, }; } diff --git a/test/parallel/test-stream-iter-pull-async.js b/test/parallel/test-stream-iter-pull-async.js index 3159b718f27e..fca2b4da293a 100644 --- a/test/parallel/test-stream-iter-pull-async.js +++ b/test/parallel/test-stream-iter-pull-async.js @@ -170,6 +170,35 @@ async function testPullSignalAbortWhileSourceNextPending() { await assert.rejects(next, { name: 'AbortError' }); } +async function testPullReturnWhileSourceNextPending() { + let startNext; + const nextStarted = new Promise((resolve) => { startNext = resolve; }); + const source = { + [Symbol.asyncIterator]() { + return { + next() { + startNext(); + return new Promise(() => {}); + }, + }; + }, + }; + + const iter = pull(source)[Symbol.asyncIterator](); + const next = assert.rejects(iter.next(), { name: 'AbortError' }); + await nextStarted; + + const timeout = {}; + const result = await Promise.race([ + iter.return(), + new Promise((resolve) => setImmediate(resolve, timeout)), + ]); + + assert.notStrictEqual(result, timeout); + assert.deepStrictEqual(result, { value: undefined, done: true }); + await next; +} + async function testPullSignalAbortWithTransformWhileSourceNextPending() { const source = { [Symbol.asyncIterator]() { @@ -417,6 +446,7 @@ async function testTransformOptionsNotShared() { testTapCallbackError(), testPullSignalAbortMidIteration(), testPullSignalAbortWhileSourceNextPending(), + testPullReturnWhileSourceNextPending(), testPullSignalAbortWithTransformWhileSourceNextPending(), testPullConsumerBreakCleanup(), testPullTransformReturnsPromise(), From d8d61b8eea8ed008534e38fb1e45a8c17cd23d40 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 29 Aug 2026 23:30:50 +0000 Subject: [PATCH 02/23] stream: ensure iterator cleanup on done, reject, etc Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/broadcast.js | 9 +-- lib/internal/streams/iter/pull.js | 72 ++++++++++++++++++++ lib/internal/streams/iter/push.js | 10 +-- lib/internal/streams/iter/share.js | 10 +-- test/parallel/test-stream-iter-pull-async.js | 37 +++++++++- 5 files changed, 115 insertions(+), 23 deletions(-) diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js index 0674d4d57037..0b03dd36c6f3 100644 --- a/lib/internal/streams/iter/broadcast.js +++ b/lib/internal/streams/iter/broadcast.js @@ -52,7 +52,7 @@ const { } = require('internal/streams/iter/from'); const { - pull: pullWithTransforms, + pullWithConsumerCleanup, } = require('internal/streams/iter/pull'); const { @@ -157,12 +157,7 @@ class BroadcastImpl { // When no transforms, return rawConsumer directly (controller elided // per PULL-02 optimization -- no transforms means no signal recipient). if (transforms.length > 0 || signal) { - const pullArgs = [...transforms]; - if (signal) { - ArrayPrototypePush(pullArgs, - { __proto__: null, signal }); - } - return pullWithTransforms(rawConsumer, ...pullArgs); + return pullWithConsumerCleanup(rawConsumer, transforms, signal); } return rawConsumer; } diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index aef7a7cacd58..465f6666c651 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -12,6 +12,7 @@ const { ArrayIsArray, ArrayPrototypePush, ArrayPrototypeSlice, + FunctionPrototypeCall, PromisePrototypeThen, PromiseResolve, SymbolAsyncIterator, @@ -896,6 +897,76 @@ function pull(source, ...args) { }; } +// Keep ownership of a bonded consumer outside the transform pipeline so it can +// be detached even when the pipeline never starts or terminates early. +function pullWithConsumerCleanup(source, transforms, signal) { + const sourceIterator = source[SymbolAsyncIterator](); + const pipelineSource = { + __proto__: null, + [SymbolAsyncIterator]() { + return sourceIterator; + }, + }; + const pipeline = signal === undefined ? + pull(pipelineSource, ...transforms) : + pull(pipelineSource, ...transforms, { __proto__: null, signal }); + let sourceClosed = false; + let abortHandler; + + function closeSource(method, value) { + if (sourceClosed) return; + sourceClosed = true; + if (abortHandler !== undefined) { + signal.removeEventListener('abort', abortHandler); + } + const close = sourceIterator[method] ?? sourceIterator.return; + if (typeof close === 'function') { + const result = FunctionPrototypeCall(close, sourceIterator, value); + PromisePrototypeThen(PromiseResolve(result), undefined, () => {}); + } + } + + if (signal !== undefined) { + abortHandler = () => closeSource('throw', signal.reason); + signal.addEventListener('abort', abortHandler, + { __proto__: null, once: true }); + if (signal.aborted) abortHandler(); + } + + return { + __proto__: null, + [SymbolAsyncIterator]() { + const iterator = pipeline[SymbolAsyncIterator](); + return { + __proto__: null, + next(value) { + return PromisePrototypeThen( + iterator.next(value), + (result) => { + if (result.done) closeSource('return'); + return result; + }, + (error) => { + closeSource('throw', error); + throw error; + }); + }, + return(value) { + closeSource('return', value); + return iterator.return(value); + }, + throw(error) { + closeSource('throw', error); + return iterator.throw(error); + }, + [SymbolAsyncIterator]() { + return this; + }, + }; + }, + }; +} + // ============================================================================= // Public API: pipeTo() and pipeToSync() // ============================================================================= @@ -1112,4 +1183,5 @@ module.exports = { pipeToSync, pull, pullSync, + pullWithConsumerCleanup, }; diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index cc7bfc900cec..3f5c22217916 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -46,7 +46,7 @@ const { } = require('internal/streams/iter/utils'); const { - pull: pullWithTransforms, + pullWithConsumerCleanup, } = require('internal/streams/iter/pull'); const { @@ -756,12 +756,8 @@ function push(...args) { // Apply transforms lazily if provided let readable; if (transforms.length > 0) { - if (options.signal) { - readable = pullWithTransforms( - rawReadable, ...transforms, { __proto__: null, signal: options.signal }); - } else { - readable = pullWithTransforms(rawReadable, ...transforms); - } + readable = pullWithConsumerCleanup( + rawReadable, transforms, options.signal); } else { readable = rawReadable; } diff --git a/lib/internal/streams/iter/share.js b/lib/internal/streams/iter/share.js index 711abeb21b9a..0d9e83dfc1ba 100644 --- a/lib/internal/streams/iter/share.js +++ b/lib/internal/streams/iter/share.js @@ -30,8 +30,8 @@ const { } = require('internal/streams/iter/from'); const { - pull: pullWithTransforms, pullSync: pullSyncWithTransforms, + pullWithConsumerCleanup, } = require('internal/streams/iter/pull'); const { @@ -111,13 +111,7 @@ class ShareImpl { const rawConsumer = this.#createRawConsumer(); if (transforms.length > 0 || signal) { - if (signal) { - return pullWithTransforms( - rawConsumer, - ...transforms, - { __proto__: null, signal }); - } - return pullWithTransforms(rawConsumer, ...transforms); + return pullWithConsumerCleanup(rawConsumer, transforms, signal); } return rawConsumer; } diff --git a/test/parallel/test-stream-iter-pull-async.js b/test/parallel/test-stream-iter-pull-async.js index fca2b4da293a..86c790c85cb2 100644 --- a/test/parallel/test-stream-iter-pull-async.js +++ b/test/parallel/test-stream-iter-pull-async.js @@ -3,7 +3,15 @@ const common = require('../common'); const assert = require('assert'); -const { pull, from, text, tap } = require('stream/iter'); +const { + broadcast, + from, + pull, + push, + share, + tap, + text, +} = require('stream/iter'); async function testPullIdentity() { const data = await text(pull(from('hello-async'))); @@ -199,6 +207,32 @@ async function testPullReturnWhileSourceNextPending() { await next; } +async function testTransformedConsumerReturnBeforeNext() { + const identity = (chunks) => chunks; + const pushed = push(identity); + const { broadcast: bc } = broadcast(); + const broadcastConsumer = bc.push(identity); + const shared = share(from('shared')); + const sharedConsumer = shared.pull(identity); + + assert.strictEqual(bc.consumerCount, 1); + assert.strictEqual(shared.consumerCount, 1); + + const cases = [ + [pushed.readable, common.mustCall( + () => assert.strictEqual(pushed.writer.canWrite, null))], + [broadcastConsumer, common.mustCall( + () => assert.strictEqual(bc.consumerCount, 0))], + [sharedConsumer, common.mustCall( + () => assert.strictEqual(shared.consumerCount, 0))], + ]; + + for (const [readable, verify] of cases) { + await readable[Symbol.asyncIterator]().return(); + verify(); + } +} + async function testPullSignalAbortWithTransformWhileSourceNextPending() { const source = { [Symbol.asyncIterator]() { @@ -447,6 +481,7 @@ async function testTransformOptionsNotShared() { testPullSignalAbortMidIteration(), testPullSignalAbortWhileSourceNextPending(), testPullReturnWhileSourceNextPending(), + testTransformedConsumerReturnBeforeNext(), testPullSignalAbortWithTransformWhileSourceNextPending(), testPullConsumerBreakCleanup(), testPullTransformReturnsPromise(), From 0eecbb8cd5641cca7d5970e277ca65a4d4c5eb1b Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 29 Aug 2026 23:37:56 +0000 Subject: [PATCH 03/23] stream: fixup writer to terminate on consumer return/throw Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/push.js | 51 ++++++++++++------- test/parallel/test-stream-iter-push-writer.js | 15 ++++++ 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index 3f5c22217916..650fc7c91a31 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -425,6 +425,13 @@ class PushQueue { // =========================================================================== async read() { + if (this.#consumerState === 'returned') { + return { __proto__: null, done: true, value: undefined }; + } + if (this.#consumerState === 'thrown') { + throw this.#error; + } + // If there's data in the buffer, return it immediately if (this.#slots.length > 0) { const result = this.#drain(); @@ -458,16 +465,9 @@ class PushQueue { consumerReturn() { if (this.#consumerState !== 'active') return; this.#consumerState = 'returned'; - this.#cleanup(); + const error = new ERR_INVALID_STATE.TypeError('Stream closed by consumer'); + this.#terminateWriterFromConsumer(error); this.#resolvePendingReads(); - this.#rejectPendingWrites( - new ERR_INVALID_STATE.TypeError('Stream closed by consumer')); - // If closing, reject the pending end promise - if (this.#writerState === 'closing' && this.#pendingEnd) { - this.#pendingEnd.reject( - new ERR_INVALID_STATE.TypeError('Stream closed by consumer')); - this.#pendingEnd = null; - } // Resolve pending drains with false - no more data will be consumed this.#resolvePendingDrains(false); } @@ -476,13 +476,8 @@ class PushQueue { if (this.#consumerState !== 'active') return; this.#consumerState = 'thrown'; this.#error = error; - this.#cleanup(); + this.#terminateWriterFromConsumer(error); this.#rejectPendingReads(error); - this.#rejectPendingWrites(error); - if (this.#writerState === 'closing' && this.#pendingEnd) { - this.#pendingEnd.reject(error); - this.#pendingEnd = null; - } // Reject pending drains - the consumer errored this.#rejectPendingDrains(error); } @@ -516,9 +511,30 @@ class PushQueue { return size; } + #terminateWriterFromConsumer(error) { + this.#slots.clear(); + this.#bufferedBytes = 0; + if (this.#writerState === 'open' || this.#writerState === 'closing') { + this.#writerState = 'errored'; + this.#error = error; + } + this.#cleanup(); + this.#rejectPendingWrites(error); + if (this.#pendingEnd) { + this.#pendingEnd.reject(error); + this.#pendingEnd = null; + } + } + #resolvePendingReads() { while (this.#pendingReads.length > 0) { - if (this.#slots.length > 0) { + if (this.#consumerState === 'returned') { + const pending = this.#pendingReads.shift(); + pending.resolve({ __proto__: null, done: true, value: undefined }); + } else if (this.#consumerState === 'thrown') { + const pending = this.#pendingReads.shift(); + pending.reject(this.#error); + } else if (this.#slots.length > 0) { const pending = this.#pendingReads.shift(); const result = this.#drain(); this.#resolvePendingWrites(); @@ -533,9 +549,6 @@ class PushQueue { } else if (this.#writerState === 'errored') { const pending = this.#pendingReads.shift(); pending.reject(this.#error); - } else if (this.#consumerState === 'returned') { - const pending = this.#pendingReads.shift(); - pending.resolve({ __proto__: null, done: true, value: undefined }); } else { break; } diff --git a/test/parallel/test-stream-iter-push-writer.js b/test/parallel/test-stream-iter-push-writer.js index 8ce555ca9251..dd6cf7d494b2 100644 --- a/test/parallel/test-stream-iter-push-writer.js +++ b/test/parallel/test-stream-iter-push-writer.js @@ -424,6 +424,20 @@ async function testConsumerReturnResolvesPendingRead() { assert.strictEqual(readResult.done, true); } +async function testEndRejectsAfterConsumerReturn() { + const { writer, readable } = push(); + writer.writeSync('data'); + const iter = readable[Symbol.asyncIterator](); + + await iter.return(); + + await assert.rejects( + writer.end({ signal: AbortSignal.timeout(common.platformTimeout(100)) }), + { code: 'ERR_INVALID_STATE' }, + ); + assert.strictEqual((await iter.next()).done, true); +} + // iterator.throw() rejects a pending read with the thrown error async function testConsumerThrowRejectsPendingRead() { const { readable } = push(); @@ -599,6 +613,7 @@ Promise.all([ testFailRejectsFutureReadWithFalsyReason(), testFailRejectsPendingReadWithFalsyReason(), testConsumerReturnResolvesPendingRead(), + testEndRejectsAfterConsumerReturn(), testConsumerThrowRejectsPendingRead(), testEndRejectsPendingWrites(), testEndIdempotentWhenClosed(), From ca72f6516a22e2457fa1c73ed5c45f116bd29a09 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 00:07:35 +0000 Subject: [PATCH 04/23] stream: ensure stability of stored metadata Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/broadcast.js | 99 +++++----- lib/internal/streams/iter/consumers.js | 47 +++-- lib/internal/streams/iter/pull.js | 86 +++++---- lib/internal/streams/iter/push.js | 88 +++++---- lib/internal/streams/iter/share.js | 53 +++--- lib/internal/streams/iter/utils.js | 70 +++++++ .../test-stream-iter-resizable-buffers.js | 177 ++++++++++++++++++ 7 files changed, 463 insertions(+), 157 deletions(-) create mode 100644 test/parallel/test-stream-iter-resizable-buffers.js diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js index 0b03dd36c6f3..11c616842e67 100644 --- a/lib/internal/streams/iter/broadcast.js +++ b/lib/internal/streams/iter/broadcast.js @@ -21,7 +21,6 @@ const { SymbolAsyncDispose, SymbolAsyncIterator, SymbolDispose, - TypedArrayPrototypeGetByteLength, } = primordials; const { lazyDOMException } = require('internal/util'); @@ -59,6 +58,7 @@ const { kMultiConsumerDefaultBudget, kResolvedPromise, convertChunks, + createBatchEntry, getWriterSignal, getMinCursor, hasProtocol, @@ -67,6 +67,7 @@ const { wrapError, toUint8Array, validateBackpressure, + validateBatchEntry, } = require('internal/streams/iter/utils'); const { @@ -215,7 +216,8 @@ class BroadcastImpl { const bufferIndex = state.cursor - self.#bufferStart; if (bufferIndex < self.#buffer.length) { - const chunk = self.#buffer.get(bufferIndex); + const chunk = self.#readEntry(self.#buffer.get(bufferIndex)); + if (chunk === null) return PromiseReject(self.#error); const cursor = state.cursor; state.cursor++; if (cursor === self.#cachedMinCursor && @@ -304,10 +306,10 @@ class BroadcastImpl { // Methods accessed by BroadcastWriter via symbol keys - [kWrite](chunk) { + [kWrite](entry) { if (this.#ended || this.#cancelled) return false; - const batchSize = this.#batchByteSize(chunk); + const batchSize = entry.byteLength; // Skip empty chunks -- zero-byte writes would accumulate infinitely // without ever triggering backpressure under a byte-budget model. @@ -322,7 +324,7 @@ class BroadcastImpl { while (this.#bufferedBytes >= this.#options.budget && this.#buffer.length > 0) { const evicted = this.#buffer.shift(); - this.#bufferedBytes -= this.#batchByteSize(evicted); + this.#bufferedBytes -= evicted.byteLength; this.#bufferStart++; } for (const consumer of this.#consumers) { @@ -338,7 +340,7 @@ class BroadcastImpl { } } - this.#buffer.push(chunk); + this.#buffer.push(entry); this.#bufferedBytes += batchSize; this.#notifyConsumers(); return true; @@ -352,7 +354,8 @@ class BroadcastImpl { while (consumer.resolve) { const bufferIndex = consumer.cursor - this.#bufferStart; if (bufferIndex < this.#buffer.length) { - const chunk = this.#buffer.get(bufferIndex); + const chunk = this.#readEntry(this.#buffer.get(bufferIndex)); + if (chunk === null) return; const cursor = consumer.cursor; consumer.cursor++; if (cursor === this.#cachedMinCursor && @@ -433,7 +436,7 @@ class BroadcastImpl { if (trimCount > 0) { for (let i = 0; i < trimCount; i++) { const evicted = this.#buffer.get(i); - this.#bufferedBytes -= this.#batchByteSize(evicted); + this.#bufferedBytes -= evicted.byteLength; } this.#buffer.trimFront(trimCount); this.#bufferStart = this.#cachedMinCursor; @@ -445,12 +448,16 @@ class BroadcastImpl { } } - #batchByteSize(batch) { - let size = 0; - for (let i = 0; i < batch.length; i++) { - size += TypedArrayPrototypeGetByteLength(batch[i]); + #readEntry(entry) { + try { + return validateBatchEntry(entry); + } catch (error) { + this.#writer.fail(error); + if (this.#error === undefined) this[kAbort](error); + this.#buffer.clear(); + this.#bufferedBytes = 0; + return null; } - return size; } #notifyConsumers() { @@ -464,7 +471,8 @@ class BroadcastImpl { if (consumer.resolve) { const bufferIndex = consumer.cursor - this.#bufferStart; if (bufferIndex < this.#buffer.length) { - const chunk = this.#buffer.get(bufferIndex); + const chunk = this.#readEntry(this.#buffer.get(bufferIndex)); + if (chunk === null) return; const cursor = consumer.cursor; consumer.cursor++; if (cursor === this.#cachedMinCursor && @@ -587,8 +595,9 @@ class BroadcastWriter { // Fast path: no signal, writer open, buffer has space if (this.#canUseWriteFastPath(signal)) { const converted = toUint8Array(chunk); - this.#broadcast[kWrite]([converted]); - this.#totalBytes += TypedArrayPrototypeGetByteLength(converted); + const batch = createBatchEntry([converted]); + this.#broadcast[kWrite](batch); + this.#totalBytes += batch.byteLength; return kResolvedPromise; } return this.#writevSlow([chunk], signal); @@ -600,10 +609,9 @@ class BroadcastWriter { // Fast path: no signal, writer open, buffer has space if (this.#canUseWriteFastPath(signal)) { const converted = convertChunks(chunks); - this.#broadcast[kWrite](converted); - for (let i = 0; i < converted.length; i++) { - this.#totalBytes += TypedArrayPrototypeGetByteLength(converted[i]); - } + const batch = createBatchEntry(converted); + this.#broadcast[kWrite](batch); + this.#totalBytes += batch.byteLength; return kResolvedPromise; } return this.#writevSlow(chunks, signal); @@ -619,12 +627,10 @@ class BroadcastWriter { signal?.throwIfAborted(); - const converted = convertChunks(chunks); + const batch = createBatchEntry(convertChunks(chunks)); - if (this.#broadcast[kWrite](converted)) { - for (let i = 0; i < converted.length; i++) { - this.#totalBytes += TypedArrayPrototypeGetByteLength(converted[i]); - } + if (this.#broadcast[kWrite](batch)) { + this.#totalBytes += batch.byteLength; return; } @@ -636,11 +642,11 @@ class BroadcastWriter { 'Backpressure violation: too many pending writes. ' + 'Await each write() call to respect backpressure.'); } - return this.#createPendingWrite(converted, signal); + return this.#createPendingWrite(batch, signal); } // 'unbounded' policy - return this.#createPendingWrite(converted, signal); + return this.#createPendingWrite(batch, signal); } writeSync(chunk) { @@ -648,8 +654,9 @@ class BroadcastWriter { if (!this.#broadcast[kCanWrite]()) return false; const converted = toUint8Array(chunk); - if (this.#broadcast[kWrite]([converted])) { - this.#totalBytes += TypedArrayPrototypeGetByteLength(converted); + const batch = createBatchEntry([converted]); + if (this.#broadcast[kWrite](batch)) { + this.#totalBytes += batch.byteLength; return true; } return false; @@ -660,10 +667,9 @@ class BroadcastWriter { if (this.#state !== 'open') return false; if (!this.#broadcast[kCanWrite]()) return false; const converted = convertChunks(chunks); - if (this.#broadcast[kWrite](converted)) { - for (let i = 0; i < converted.length; i++) { - this.#totalBytes += TypedArrayPrototypeGetByteLength(converted[i]); - } + const batch = createBatchEntry(converted); + if (this.#broadcast[kWrite](batch)) { + this.#totalBytes += batch.byteLength; return true; } return false; @@ -752,9 +758,9 @@ class BroadcastWriter { * promise rejects. Signal listeners are cleaned up on normal resolution. * @returns {Promise} */ - #createPendingWrite(chunk, signal) { + #createPendingWrite(batch, signal) { const { promise, resolve, reject } = PromiseWithResolvers(); - const entry = { __proto__: null, chunk, resolve, reject }; + const entry = { __proto__: null, batch, resolve, reject }; this.#pendingWrites.push(entry); if (signal) { wireBroadcastWriteSignal(entry, signal, resolve, reject, this); @@ -765,14 +771,17 @@ class BroadcastWriter { #resolvePendingWrites() { while (this.#pendingWrites.length > 0 && this.#broadcast[kCanWrite]()) { const pending = this.#pendingWrites.shift(); - if (this.#broadcast[kWrite](pending.chunk)) { - for (let i = 0; i < pending.chunk.length; i++) { - this.#totalBytes += TypedArrayPrototypeGetByteLength(pending.chunk[i]); + try { + validateBatchEntry(pending.batch); + if (this.#broadcast[kWrite](pending.batch)) { + this.#totalBytes += pending.batch.byteLength; + pending.resolve(); + } else { + this.#pendingWrites.unshift(pending); + break; } - pending.resolve(); - } else { - this.#pendingWrites.unshift(pending); - break; + } catch (error) { + pending.reject(error); } } this.#finishEndIfReady(); @@ -806,18 +815,18 @@ function wireBroadcastWriteSignal(entry, signal, resolve, reject, self) { const pendingWrites = getBroadcastPendingWrites(self); const idx = pendingWrites.indexOf(entry); if (idx !== -1) pendingWrites.removeAt(idx); - entry.chunk = null; + entry.batch = null; reject(signal.reason ?? lazyDOMException('Aborted', 'AbortError')); if (idx !== -1) self[kPendingWriteRemoved](); }; entry.resolve = function() { signal.removeEventListener('abort', onAbort); - entry.chunk = null; + entry.batch = null; resolve(); }; entry.reject = function(reason) { signal.removeEventListener('abort', onAbort); - entry.chunk = null; + entry.batch = null; reject(reason); }; signal.addEventListener('abort', onAbort, { __proto__: null, once: true }); diff --git a/lib/internal/streams/iter/consumers.js b/lib/internal/streams/iter/consumers.js index e3bd7856dcd4..30f4eb4f53da 100644 --- a/lib/internal/streams/iter/consumers.js +++ b/lib/internal/streams/iter/consumers.js @@ -54,6 +54,8 @@ const { const { concatBytes, + createBatchEntry, + validateBatchEntry, yieldAbortable, } = require('internal/streams/iter/utils'); @@ -89,6 +91,17 @@ function isMergeOptions(value) { // Shared chunk collection helpers // ============================================================================= +function flattenBatchEntries(entries) { + const chunks = []; + for (let i = 0; i < entries.length; i++) { + const batch = validateBatchEntry(entries[i]); + for (let j = 0; j < batch.length; j++) { + ArrayPrototypePush(chunks, batch[j]); + } + } + return chunks; +} + /** * Collect chunks from a sync source into an array. * @param {Iterable} source @@ -98,23 +111,23 @@ function isMergeOptions(value) { function collectSync(source, limit) { // Normalize source via fromSync() - accepts strings, ArrayBuffers, protocols, etc. const normalized = fromSync(source); - const chunks = []; + const entries = []; let totalBytes = 0; for (const batch of normalized) { - for (let i = 0; i < batch.length; i++) { - const chunk = batch[i]; - if (limit !== undefined) { - totalBytes += TypedArrayPrototypeGetByteLength(chunk); + const entry = createBatchEntry(batch); + if (limit !== undefined) { + for (let i = 0; i < entry.views.length; i++) { + totalBytes += entry.views[i].byteLength; if (totalBytes > limit) { throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes); } } - ArrayPrototypePush(chunks, chunk); } + ArrayPrototypePush(entries, entry); } - return chunks; + return flattenBatchEntries(entries); } /** @@ -131,16 +144,14 @@ async function collectAsync(source, signal, limit) { const abortableSource = signal && isAsyncIterable(source) ? yieldAbortable(source, signal) : source; const normalized = from(abortableSource); - const chunks = []; + const entries = []; // Fast path: no signal and no limit if (!signal && limit === undefined) { for await (const batch of normalized) { - for (let i = 0; i < batch.length; i++) { - ArrayPrototypePush(chunks, batch[i]); - } + ArrayPrototypePush(entries, createBatchEntry(batch)); } - return chunks; + return flattenBatchEntries(entries); } // Slow path: with signal or limit checks @@ -149,19 +160,19 @@ async function collectAsync(source, signal, limit) { for await (const batch of iterable) { signal?.throwIfAborted(); - for (let i = 0; i < batch.length; i++) { - const chunk = batch[i]; - if (limit !== undefined) { - totalBytes += TypedArrayPrototypeGetByteLength(chunk); + const entry = createBatchEntry(batch); + if (limit !== undefined) { + for (let i = 0; i < entry.views.length; i++) { + totalBytes += entry.views[i].byteLength; if (totalBytes > limit) { throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes); } } - ArrayPrototypePush(chunks, chunk); } + ArrayPrototypePush(entries, entry); } - return chunks; + return flattenBatchEntries(entries); } /** diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index 465f6666c651..a9a77377a84e 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -17,7 +17,6 @@ const { PromiseResolve, SymbolAsyncIterator, SymbolIterator, - TypedArrayPrototypeGetByteLength, Uint8Array, } = primordials; @@ -52,11 +51,14 @@ const { } = require('internal/streams/iter/from'); const { + createBatchEntry, isPullOptions, isTransform, isTransformObject, parsePullArgs, toUint8Array, + validateBatchEntry, + validateByteView, wrapError, yieldAbortable, } = require('internal/streams/iter/utils'); @@ -992,22 +994,26 @@ function pipeToSync(source, ...args) { try { for (const batch of pipeline) { + const entry = createBatchEntry(batch); if (hasWritevSync && batch.length > 1) { - if (writer.writevSync(batch) === false) { + const accepted = writer.writevSync(validateBatchEntry(entry)); + validateBatchEntry(entry); + if (accepted === false) { throw new ERR_OUT_OF_RANGE( 'write', 'within byte budget', 'budget exhausted'); } - for (let i = 0; i < batch.length; i++) { - totalBytes += TypedArrayPrototypeGetByteLength(batch[i]); - } + totalBytes += entry.byteLength; } else { - for (let i = 0; i < batch.length; i++) { - const chunk = batch[i]; - if (writer.writeSync(chunk) === false) { + for (let i = 0; i < entry.views.length; i++) { + const view = entry.views[i]; + const chunk = validateByteView(view); + const accepted = writer.writeSync(chunk); + validateByteView(view); + if (accepted === false) { throw new ERR_OUT_OF_RANGE( 'write', 'within byte budget', 'budget exhausted'); } - totalBytes += TypedArrayPrototypeGetByteLength(chunk); + totalBytes += view.byteLength; } } } @@ -1056,19 +1062,26 @@ async function pipeTo(source, ...args) { // Async fallback for writeBatch when sync write fails partway through. // Continues writing from batch[startIndex] using async write(). - async function writeBatchAsyncFallback(batch, startIndex) { - for (let i = startIndex; i < batch.length; i++) { - const chunk = batch[i]; - if (hasWriteSync && writer.writeSync(chunk)) { - // Sync retry succeeded - } else { - const result = writer.write( - chunk, signal ? { __proto__: null, signal } : undefined); - if (result !== undefined) { - await result; + async function writeBatchAsyncFallback(entry, startIndex) { + for (let i = startIndex; i < entry.views.length; i++) { + const view = entry.views[i]; + if (hasWriteSync) { + const chunk = validateByteView(view); + if (writer.writeSync(chunk)) { + validateByteView(view); + totalBytes += view.byteLength; + continue; } + validateByteView(view); + } + const result = writer.write( + validateByteView(view), + signal ? { __proto__: null, signal } : undefined); + if (result !== undefined) { + await result; } - totalBytes += TypedArrayPrototypeGetByteLength(chunk); + validateByteView(view); + totalBytes += view.byteLength; } } @@ -1076,34 +1089,37 @@ async function pipeTo(source, ...args) { // Returns undefined on sync success, or a Promise when async fallback // is required. Callers must check: const p = writeBatch(b); if (p) await p; function writeBatch(batch) { + const entry = createBatchEntry(batch); if (hasWritev && batch.length > 1) { - if (!hasWritevSync || !writer.writevSync(batch)) { + if (!hasWritevSync || + !writer.writevSync(validateBatchEntry(entry))) { + validateBatchEntry(entry); const opts = signal ? { __proto__: null, signal } : undefined; - const writevResult = writer.writev(batch, opts); + const writevResult = writer.writev(validateBatchEntry(entry), opts); if (writevResult === undefined) { - for (let i = 0; i < batch.length; i++) { - totalBytes += TypedArrayPrototypeGetByteLength(batch[i]); - } + validateBatchEntry(entry); + totalBytes += entry.byteLength; return; } return PromisePrototypeThen(PromiseResolve(writevResult), () => { - for (let i = 0; i < batch.length; i++) { - totalBytes += TypedArrayPrototypeGetByteLength(batch[i]); - } + validateBatchEntry(entry); + totalBytes += entry.byteLength; }); } - for (let i = 0; i < batch.length; i++) { - totalBytes += TypedArrayPrototypeGetByteLength(batch[i]); - } + validateBatchEntry(entry); + totalBytes += entry.byteLength; return; } - for (let i = 0; i < batch.length; i++) { - const chunk = batch[i]; + for (let i = 0; i < entry.views.length; i++) { + const view = entry.views[i]; + const chunk = validateByteView(view); if (!hasWriteSync || !writer.writeSync(chunk)) { + if (hasWriteSync) validateByteView(view); // Sync path failed at index i - fall back to async for the rest. - return writeBatchAsyncFallback(batch, i); + return writeBatchAsyncFallback(entry, i); } - totalBytes += TypedArrayPrototypeGetByteLength(chunk); + validateByteView(view); + totalBytes += view.byteLength; } } diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index 650fc7c91a31..368a121f4106 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -15,7 +15,6 @@ const { SymbolAsyncDispose, SymbolAsyncIterator, SymbolDispose, - TypedArrayPrototypeGetByteLength, } = primordials; const { @@ -37,12 +36,14 @@ const { const { kPushDefaultBudget, kResolvedPromise, + createBatchEntry, onSignalAbort, toUint8Array, convertChunks, getWriterSignal, parsePullArgs, validateBackpressure, + validateBatchEntry, } = require('internal/streams/iter/utils'); const { @@ -185,7 +186,11 @@ class PushQueue { if (this.#writerState !== 'open') return false; if (this.#consumerState !== 'active') return false; - const batchSize = this.#batchByteSize(chunks); + return this.#writeEntry(createBatchEntry(chunks)); + } + + #writeEntry(entry) { + const batchSize = entry.byteLength; // Skip empty chunks -- zero-byte writes would accumulate infinitely // without ever triggering backpressure under a byte-budget model. @@ -201,7 +206,7 @@ class PushQueue { while (this.#bufferedBytes >= this.#budget && this.#slots.length > 0) { const evicted = this.#slots.shift(); - this.#bufferedBytes -= this.#batchByteSize(evicted); + this.#bufferedBytes -= evicted.byteLength; } break; case 'drop-newest': @@ -211,7 +216,7 @@ class PushQueue { } } - this.#slots.push(chunks); + this.#slots.push(entry); this.#bufferedBytes += batchSize; this.#bytesWritten += batchSize; @@ -253,8 +258,8 @@ class PushQueue { // Check for pre-aborted signal (after state checks per spec) signal?.throwIfAborted(); - // Try sync first - if (this.writeSync(chunks)) { + const entry = createBatchEntry(chunks); + if (this.#writeEntry(entry)) { return; } @@ -266,9 +271,9 @@ class PushQueue { 'Backpressure violation: too many pending writes. ' + 'Await each write() call to respect backpressure.'); } - return this.#createPendingWrite(chunks, signal); + return this.#createPendingWrite(entry, signal); case 'unbounded': - return this.#createPendingWrite(chunks, signal); + return this.#createPendingWrite(entry, signal); default: throw new ERR_INVALID_STATE( 'Unexpected: writeSync should have handled non-strict policy'); @@ -281,9 +286,9 @@ class PushQueue { * promise rejects. Signal listeners are cleaned up on normal resolution. * @returns {Promise} */ - #createPendingWrite(chunks, signal) { + #createPendingWrite(batch, signal) { const { promise, resolve, reject } = PromiseWithResolvers(); - const entry = { __proto__: null, chunks, resolve, reject }; + const entry = { __proto__: null, batch, resolve, reject }; this.#pendingWrites.push(entry); if (signal) { @@ -487,28 +492,29 @@ class PushQueue { // =========================================================================== #drain() { - this.#bufferedBytes = 0; - if (this.#slots.length === 1) { - return this.#slots.shift(); - } - - const result = []; - for (let i = 0; i < this.#slots.length; i++) { - const slot = this.#slots.get(i); - for (let j = 0; j < slot.length; j++) { - ArrayPrototypePush(result, slot[j]); + try { + if (this.#slots.length === 1) { + const result = validateBatchEntry(this.#slots.shift()); + this.#bufferedBytes = 0; + return result; } - } - this.#slots.clear(); - return result; - } - #batchByteSize(batch) { - let size = 0; - for (let i = 0; i < batch.length; i++) { - size += TypedArrayPrototypeGetByteLength(batch[i]); + const result = []; + for (let i = 0; i < this.#slots.length; i++) { + const batch = validateBatchEntry(this.#slots.get(i)); + for (let j = 0; j < batch.length; j++) { + ArrayPrototypePush(result, batch[j]); + } + } + this.#slots.clear(); + this.#bufferedBytes = 0; + return result; + } catch (error) { + this.#slots.clear(); + this.#bufferedBytes = 0; + this.fail(error); + throw error; } - return size; } #terminateWriterFromConsumer(error) { @@ -536,9 +542,13 @@ class PushQueue { pending.reject(this.#error); } else if (this.#slots.length > 0) { const pending = this.#pendingReads.shift(); - const result = this.#drain(); - this.#resolvePendingWrites(); - pending.resolve({ __proto__: null, done: false, value: result }); + try { + const result = this.#drain(); + this.#resolvePendingWrites(); + pending.resolve({ __proto__: null, done: false, value: result }); + } catch (error) { + pending.reject(error); + } } else if (this.#writerState === 'closing' && this.#slots.length === 0) { this.endDrained(); const pending = this.#pendingReads.shift(); @@ -559,11 +569,15 @@ class PushQueue { while (this.#pendingWrites.length > 0 && this.#bufferedBytes < this.#budget) { const pending = this.#pendingWrites.shift(); - const batchSize = this.#batchByteSize(pending.chunks); - this.#slots.push(pending.chunks); - this.#bufferedBytes += batchSize; - this.#bytesWritten += batchSize; - pending.resolve(); + try { + validateBatchEntry(pending.batch); + this.#slots.push(pending.batch); + this.#bufferedBytes += pending.batch.byteLength; + this.#bytesWritten += pending.batch.byteLength; + pending.resolve(); + } catch (error) { + pending.reject(error); + } } if (this.#bufferedBytes < this.#budget) { diff --git a/lib/internal/streams/iter/share.js b/lib/internal/streams/iter/share.js index 0d9e83dfc1ba..2cb4ed7fa469 100644 --- a/lib/internal/streams/iter/share.js +++ b/lib/internal/streams/iter/share.js @@ -14,7 +14,6 @@ const { SymbolAsyncIterator, SymbolDispose, SymbolIterator, - TypedArrayPrototypeGetByteLength, } = primordials; const { @@ -36,12 +35,14 @@ const { const { kMultiConsumerDefaultBudget, + createBatchEntry, getMinCursor, hasProtocol, onSignalAbort, wrapError, parsePullArgs, validateBackpressure, + validateBatchEntry, } = require('internal/streams/iter/utils'); const { @@ -166,7 +167,7 @@ class ShareImpl { // Check if data is available in buffer const bufferIndex = state.cursor - self.#bufferStart; if (bufferIndex < self.#buffer.length) { - const chunk = self.#buffer.get(bufferIndex); + const chunk = self.#readEntry(self.#buffer.get(bufferIndex)); const cursor = state.cursor; state.cursor++; if (cursor === self.#cachedMinCursor && @@ -296,7 +297,7 @@ class ShareImpl { while (this.#bufferedBytes >= this.#options.budget && this.#buffer.length > 0) { const evicted = this.#buffer.shift(); - this.#bufferedBytes -= this.#batchByteSize(evicted); + this.#bufferedBytes -= evicted.byteLength; this.#bufferStart++; } for (const consumer of this.#consumers) { @@ -368,8 +369,9 @@ class ShareImpl { if (result.done) { this.#sourceExhausted = true; } else if (!discard) { - this.#buffer.push(result.value); - this.#bufferedBytes += this.#batchByteSize(result.value); + const entry = createBatchEntry(result.value); + this.#buffer.push(entry); + this.#bufferedBytes += entry.byteLength; } } catch (error) { this.#sourceError = wrapError(error); @@ -392,7 +394,7 @@ class ShareImpl { if (trimCount > 0) { for (let i = 0; i < trimCount; i++) { const evicted = this.#buffer.get(i); - this.#bufferedBytes -= this.#batchByteSize(evicted); + this.#bufferedBytes -= evicted.byteLength; } this.#buffer.trimFront(trimCount); this.#bufferStart = this.#cachedMinCursor; @@ -403,12 +405,15 @@ class ShareImpl { } } - #batchByteSize(batch) { - let size = 0; - for (let i = 0; i < batch.length; i++) { - size += TypedArrayPrototypeGetByteLength(batch[i]); + #readEntry(entry) { + try { + return validateBatchEntry(entry); + } catch (error) { + this.cancel(error); + this.#buffer.clear(); + this.#bufferedBytes = 0; + throw error; } - return size; } #recomputeMinCursor() { @@ -511,7 +516,7 @@ class SyncShareImpl { const bufferIndex = state.cursor - self.#bufferStart; if (bufferIndex < self.#buffer.length) { - const chunk = self.#buffer.get(bufferIndex); + const chunk = self.#readEntry(self.#buffer.get(bufferIndex)); const cursor = state.cursor; state.cursor++; if (cursor === self.#cachedMinCursor && @@ -543,7 +548,7 @@ class SyncShareImpl { while (self.#bufferedBytes >= self.#options.budget && self.#buffer.length > 0) { const evicted = self.#buffer.shift(); - self.#bufferedBytes -= self.#batchByteSize(evicted); + self.#bufferedBytes -= evicted.byteLength; self.#bufferStart++; } for (const consumer of self.#consumers) { @@ -571,7 +576,7 @@ class SyncShareImpl { const newBufferIndex = state.cursor - self.#bufferStart; if (newBufferIndex < self.#buffer.length) { - const chunk = self.#buffer.get(newBufferIndex); + const chunk = self.#readEntry(self.#buffer.get(newBufferIndex)); const cursor = state.cursor; state.cursor++; if (cursor === self.#cachedMinCursor && @@ -643,8 +648,9 @@ class SyncShareImpl { if (result.done) { this.#sourceExhausted = true; } else { - this.#buffer.push(result.value); - this.#bufferedBytes += this.#batchByteSize(result.value); + const entry = createBatchEntry(result.value); + this.#buffer.push(entry); + this.#bufferedBytes += entry.byteLength; } } catch (error) { this.#sourceError = wrapError(error); @@ -660,19 +666,22 @@ class SyncShareImpl { if (trimCount > 0) { for (let i = 0; i < trimCount; i++) { const evicted = this.#buffer.get(i); - this.#bufferedBytes -= this.#batchByteSize(evicted); + this.#bufferedBytes -= evicted.byteLength; } this.#buffer.trimFront(trimCount); this.#bufferStart = this.#cachedMinCursor; } } - #batchByteSize(batch) { - let size = 0; - for (let i = 0; i < batch.length; i++) { - size += TypedArrayPrototypeGetByteLength(batch[i]); + #readEntry(entry) { + try { + return validateBatchEntry(entry); + } catch (error) { + this.cancel(error); + this.#buffer.clear(); + this.#bufferedBytes = 0; + throw error; } - return size; } #recomputeMinCursor() { diff --git a/lib/internal/streams/iter/utils.js b/lib/internal/streams/iter/utils.js index 9966f351b9d5..53610dbe6317 100644 --- a/lib/internal/streams/iter/utils.js +++ b/lib/internal/streams/iter/utils.js @@ -3,6 +3,7 @@ const { Array, ArrayBufferPrototypeGetByteLength, + ArrayBufferPrototypeGetDetached, ArrayPrototypeSlice, PromiseResolve, PromiseWithResolvers, @@ -25,6 +26,7 @@ const { TextEncoder } = require('internal/encoding'); const { codes: { ERR_INVALID_ARG_TYPE, + ERR_INVALID_STATE, ERR_OPERATION_FAILED, }, } = require('internal/errors'); @@ -196,6 +198,71 @@ function allUint8Array(chunks) { return true; } +function snapshotByteView(value) { + const buffer = TypedArrayPrototypeGetBuffer(value); + const sharedBufferView = isSharedArrayBuffer(buffer) ? + new Uint8Array(buffer) : undefined; + return { + __proto__: null, + value, + buffer, + bufferByteLength: sharedBufferView === undefined ? + ArrayBufferPrototypeGetByteLength(buffer) : + TypedArrayPrototypeGetByteLength(sharedBufferView), + byteLength: TypedArrayPrototypeGetByteLength(value), + byteOffset: TypedArrayPrototypeGetByteOffset(value), + detached: sharedBufferView === undefined && + ArrayBufferPrototypeGetDetached(buffer), + sharedBufferView, + }; +} + +function validateByteView(snapshot) { + const { + value, + buffer, + bufferByteLength, + byteLength, + byteOffset, + detached, + sharedBufferView, + } = snapshot; + const currentBufferByteLength = sharedBufferView === undefined ? + ArrayBufferPrototypeGetByteLength(buffer) : + TypedArrayPrototypeGetByteLength(sharedBufferView); + const currentDetached = sharedBufferView === undefined && + ArrayBufferPrototypeGetDetached(buffer); + + if (TypedArrayPrototypeGetBuffer(value) !== buffer || + currentBufferByteLength !== bufferByteLength || + TypedArrayPrototypeGetByteLength(value) !== byteLength || + TypedArrayPrototypeGetByteOffset(value) !== byteOffset || + currentDetached !== detached) { + throw new ERR_INVALID_STATE.TypeError( + 'Byte view was resized or detached after being accepted'); + } + return value; +} + +function createBatchEntry(chunks) { + const views = new Array(chunks.length); + let byteLength = 0; + for (let i = 0; i < chunks.length; i++) { + const view = snapshotByteView(chunks[i]); + views[i] = view; + byteLength += view.byteLength; + } + return { __proto__: null, views, byteLength }; +} + +function validateBatchEntry(entry) { + const chunks = new Array(entry.views.length); + for (let i = 0; i < entry.views.length; i++) { + chunks[i] = validateByteView(entry.views[i]); + } + return chunks; +} + function copyBytes(chunk) { const copy = new Uint8Array(TypedArrayPrototypeGetByteLength(chunk)); TypedArrayPrototypeSet(copy, chunk); @@ -382,6 +449,7 @@ module.exports = { allUint8Array, concatBytes, convertChunks, + createBatchEntry, getWriterSignal, getMinCursor, hasProtocol, @@ -392,6 +460,8 @@ module.exports = { parsePullArgs, toUint8Array, validateBackpressure, + validateBatchEntry, + validateByteView, wrapError, yieldAbortable, }; diff --git a/test/parallel/test-stream-iter-resizable-buffers.js b/test/parallel/test-stream-iter-resizable-buffers.js new file mode 100644 index 000000000000..c4be26774ac6 --- /dev/null +++ b/test/parallel/test-stream-iter-resizable-buffers.js @@ -0,0 +1,177 @@ +// Flags: --experimental-stream-iter +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { + array, + arraySync, + broadcast, + pipeTo, + pipeToSync, + push, + share, + shareSync, +} = require('stream/iter'); + +const kResizeError = { + code: 'ERR_INVALID_STATE', + message: /resized or detached/, +}; + +async function testBufferedViewMutationRejected() { + const resizable = new ArrayBuffer(1, { maxByteLength: 2 }); + const growable = new SharedArrayBuffer(1, { maxByteLength: 2 }); + const detachable = new ArrayBuffer(1); + const cases = [ + [new Uint8Array(resizable), () => resizable.resize(2)], + [new Uint8Array(growable), () => growable.grow(2)], + [new Uint8Array(detachable), () => { + structuredClone(detachable, { transfer: [detachable] }); + }], + ]; + + for (const [view, mutate] of cases) { + const { writer, readable } = push(); + assert.strictEqual(writer.writeSync(view), true); + mutate(); + await assert.rejects( + readable[Symbol.asyncIterator]().next(), + kResizeError, + ); + } +} + +async function testDropOldestUsesAcceptedByteLength() { + const buffer = new ArrayBuffer(16384, { maxByteLength: 16384 }); + const { writer, broadcast: bc } = broadcast({ + budget: 16384, + backpressure: 'drop-oldest', + }); + const iterator = bc.push()[Symbol.asyncIterator](); + + assert.strictEqual(writer.writeSync(new Uint8Array(buffer)), true); + buffer.resize(0); + assert.strictEqual(writer.writeSync(Uint8Array.of(2)), true); + assert.strictEqual(writer.writeSync(Uint8Array.of(3)), true); + writer.endSync(); + + assert.strictEqual((await iterator.next()).value[0][0], 2); + assert.strictEqual((await iterator.next()).value[0][0], 3); + assert.strictEqual((await iterator.next()).done, true); +} + +async function testPendingWritesRejectResizedViews() { + const pushResult = push({ budget: 16384, backpressure: 'unbounded' }); + assert.strictEqual( + pushResult.writer.writeSync(new Uint8Array(16384)), true); + const pushBuffer = new ArrayBuffer(1, { maxByteLength: 2 }); + const pushPending = pushResult.writer.write(new Uint8Array(pushBuffer)); + const pushRejected = assert.rejects(pushPending, kResizeError); + pushBuffer.resize(2); + const pushIterator = pushResult.readable[Symbol.asyncIterator](); + assert.strictEqual((await pushIterator.next()).done, false); + await pushRejected; + pushResult.writer.endSync(); + assert.strictEqual((await pushIterator.next()).done, true); + + const { writer, broadcast: bc } = broadcast({ + budget: 16384, + backpressure: 'unbounded', + }); + const broadcastIterator = bc.push()[Symbol.asyncIterator](); + assert.strictEqual(writer.writeSync(new Uint8Array(16384)), true); + const broadcastBuffer = new ArrayBuffer(1, { maxByteLength: 2 }); + const broadcastPending = writer.write(new Uint8Array(broadcastBuffer)); + const broadcastRejected = assert.rejects(broadcastPending, kResizeError); + broadcastBuffer.resize(2); + assert.strictEqual((await broadcastIterator.next()).done, false); + await broadcastRejected; + writer.endSync(); + assert.strictEqual((await broadcastIterator.next()).done, true); +} + +async function testBroadcastRejectsResizedBufferedView() { + const buffer = new ArrayBuffer(1, { maxByteLength: 2 }); + const { writer, broadcast: bc } = broadcast(); + const iterator = bc.push()[Symbol.asyncIterator](); + + assert.strictEqual(writer.writeSync(new Uint8Array(buffer)), true); + buffer.resize(2); + + await assert.rejects(iterator.next(), kResizeError); + await assert.rejects(writer.end(), kResizeError); +} + +async function testShareRejectsResizedBufferedView() { + const asyncBuffer = new ArrayBuffer(1, { maxByteLength: 2 }); + const shared = share([[new Uint8Array(asyncBuffer)]]); + const first = shared.pull()[Symbol.asyncIterator](); + const second = shared.pull()[Symbol.asyncIterator](); + + assert.strictEqual((await first.next()).done, false); + asyncBuffer.resize(2); + await assert.rejects(second.next(), kResizeError); + + const syncBuffer = new ArrayBuffer(1, { maxByteLength: 2 }); + const sharedSync = shareSync([[new Uint8Array(syncBuffer)]]); + const firstSync = sharedSync.pull()[Symbol.iterator](); + const secondSync = sharedSync.pull()[Symbol.iterator](); + + assert.strictEqual(firstSync.next().done, false); + syncBuffer.resize(2); + assert.throws(() => secondSync.next(), kResizeError); +} + +async function testConsumersRejectResizedViews() { + const asyncBuffer = new ArrayBuffer(1, { maxByteLength: 2 }); + async function* asyncSource() { + yield [new Uint8Array(asyncBuffer)]; + asyncBuffer.resize(2); + } + await assert.rejects(array(asyncSource(), { limit: 1 }), kResizeError); + + const syncBuffer = new ArrayBuffer(1, { maxByteLength: 2 }); + function* syncSource() { + yield [new Uint8Array(syncBuffer)]; + syncBuffer.resize(2); + } + assert.throws(() => arraySync(syncSource(), { limit: 1 }), kResizeError); +} + +async function testPipeRejectsWriterResize() { + const asyncBuffer = new ArrayBuffer(1, { maxByteLength: 2 }); + const asyncWriter = { + write() { + asyncBuffer.resize(2); + }, + fail: common.mustCall(), + }; + await assert.rejects( + pipeTo([new Uint8Array(asyncBuffer)], asyncWriter), + kResizeError, + ); + + const syncBuffer = new ArrayBuffer(1, { maxByteLength: 2 }); + const syncWriter = { + writeSync() { + syncBuffer.resize(2); + return true; + }, + fail: common.mustCall(), + }; + assert.throws( + () => pipeToSync([new Uint8Array(syncBuffer)], syncWriter), + kResizeError, + ); +} + +Promise.all([ + testBufferedViewMutationRejected(), + testDropOldestUsesAcceptedByteLength(), + testPendingWritesRejectResizedViews(), + testBroadcastRejectsResizedBufferedView(), + testShareRejectsResizedBufferedView(), + testConsumersRejectResizedViews(), + testPipeRejectsWriterResize(), +]).then(common.mustCall()); From 8b57e6d11f23cc853986500c635d43cb172b4447 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 00:16:04 +0000 Subject: [PATCH 05/23] stream: defend against re-entrancy in writev Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/broadcast.js | 21 +++++- lib/internal/streams/iter/push.js | 6 +- lib/internal/streams/iter/utils.js | 17 ----- .../test-stream-iter-writer-reentrancy.js | 65 +++++++++++++++++++ 4 files changed, 85 insertions(+), 24 deletions(-) create mode 100644 test/parallel/test-stream-iter-writer-reentrancy.js diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js index 11c616842e67..0dc6364768d5 100644 --- a/lib/internal/streams/iter/broadcast.js +++ b/lib/internal/streams/iter/broadcast.js @@ -610,9 +610,11 @@ class BroadcastWriter { if (this.#canUseWriteFastPath(signal)) { const converted = convertChunks(chunks); const batch = createBatchEntry(converted); - this.#broadcast[kWrite](batch); - this.#totalBytes += batch.byteLength; - return kResolvedPromise; + if (this.#state === 'open' && this.#broadcast[kWrite](batch)) { + this.#totalBytes += batch.byteLength; + return kResolvedPromise; + } + return this.#writeBatchSlow(batch, signal); } return this.#writevSlow(chunks, signal); } @@ -629,6 +631,19 @@ class BroadcastWriter { const batch = createBatchEntry(convertChunks(chunks)); + return this.#writeBatchSlow(batch, signal); + } + + async #writeBatchSlow(batch, signal) { + if (this.#state === 'errored') { + throw this.#error; + } + if (this.#state !== 'open') { + throw new ERR_INVALID_STATE.TypeError('Writer is closed'); + } + + signal?.throwIfAborted(); + if (this.#broadcast[kWrite](batch)) { this.#totalBytes += batch.byteLength; return; diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index 368a121f4106..36a2034e5298 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -657,12 +657,10 @@ class PushWriter { writev(chunks, options) { validateArray(chunks, 'chunks'); const signal = getWriterSignal(options); - if (!signal && this.#queue.canWriteSync()) { - const bytes = convertChunks(chunks); - this.#queue.writeSync(bytes); + const bytes = convertChunks(chunks); + if (!signal && this.#queue.writeSync(bytes)) { return kResolvedPromise; } - const bytes = convertChunks(chunks); return this.#queue.writeAsync(bytes, signal); } diff --git a/lib/internal/streams/iter/utils.js b/lib/internal/streams/iter/utils.js index 53610dbe6317..66e831a58523 100644 --- a/lib/internal/streams/iter/utils.js +++ b/lib/internal/streams/iter/utils.js @@ -185,19 +185,6 @@ function toUint8Array(chunk) { return chunk; } -/** - * Check if all chunks in an array are already Uint8Array. - * Short-circuits on the first non-Uint8Array chunk found. - * @param {Array} chunks - * @returns {boolean} - */ -function allUint8Array(chunks) { - for (let i = 0; i < chunks.length; i++) { - if (!isUint8Array(chunks[i])) return false; - } - return true; -} - function snapshotByteView(value) { const buffer = TypedArrayPrototypeGetBuffer(value); const sharedBufferView = isSharedArrayBuffer(buffer) ? @@ -317,9 +304,6 @@ function concatBytes(chunks) { * @returns {Uint8Array[]} */ function convertChunks(chunks) { - if (allUint8Array(chunks)) { - return ArrayPrototypeSlice(chunks); - } const len = chunks.length; const result = new Array(len); for (let i = 0; i < len; i++) { @@ -446,7 +430,6 @@ module.exports = { kMultiConsumerDefaultBudget, kPushDefaultBudget, kResolvedPromise, - allUint8Array, concatBytes, convertChunks, createBatchEntry, diff --git a/test/parallel/test-stream-iter-writer-reentrancy.js b/test/parallel/test-stream-iter-writer-reentrancy.js new file mode 100644 index 000000000000..119bda269239 --- /dev/null +++ b/test/parallel/test-stream-iter-writer-reentrancy.js @@ -0,0 +1,65 @@ +// Flags: --experimental-stream-iter +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { broadcast, push } = require('stream/iter'); + +const factories = [ + () => push({ budget: 16384 }), + () => { + const { writer, broadcast: bc } = broadcast({ budget: 16384 }); + return { __proto__: null, writer, readable: bc.push() }; + }, +]; + +async function testWritevReentrancy() { + for (const factory of factories) { + const { writer, readable } = factory(); + const chunks = []; + Object.defineProperty(chunks, 0, { + __proto__: null, + enumerable: true, + get: common.mustCall(() => { + assert.strictEqual( + writer.writeSync(new Uint8Array(16384)), true); + return Uint8Array.of(42); + }), + }); + + let resolved = false; + const write = writer.writev(chunks).then(common.mustCall(() => { + resolved = true; + })); + await new Promise(setImmediate); + assert.strictEqual(resolved, false); + + const iterator = readable[Symbol.asyncIterator](); + assert.strictEqual((await iterator.next()).value[0].byteLength, 16384); + await write; + assert.strictEqual((await iterator.next()).value[0][0], 42); + writer.endSync(); + assert.strictEqual((await iterator.next()).done, true); + } + + for (const factory of factories) { + const { writer, readable } = factory(); + const chunks = []; + Object.defineProperty(chunks, 0, { + __proto__: null, + enumerable: true, + get: common.mustCall(() => { + writer.endSync(); + return Uint8Array.of(42); + }), + }); + + await assert.rejects(writer.writev(chunks), { + code: 'ERR_INVALID_STATE', + }); + assert.strictEqual( + (await readable[Symbol.asyncIterator]().next()).done, true); + } +} + +testWritevReentrancy().then(common.mustCall()); From fd7d7c2de76cb392d1019c27b256247be9f7d7c8 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 00:25:27 +0000 Subject: [PATCH 06/23] stream: ensure full-close semantics when closed Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/duplex.js | 97 +++++++------------- test/parallel/test-stream-iter-duplex.js | 96 ++++++++++++------- test/parallel/test-stream-iter-validation.js | 12 +-- 3 files changed, 104 insertions(+), 101 deletions(-) diff --git a/lib/internal/streams/iter/duplex.js b/lib/internal/streams/iter/duplex.js index b37b91279232..50a04961b1f2 100644 --- a/lib/internal/streams/iter/duplex.js +++ b/lib/internal/streams/iter/duplex.js @@ -6,6 +6,7 @@ // channel's writer appears in the other channel's readable. const { + SafePromiseAllReturnVoid, SymbolAsyncDispose, SymbolAsyncIterator, } = primordials; @@ -50,68 +51,8 @@ function duplex(options = { __proto__: null }) { backpressure: b?.backpressure ?? backpressure, }); - let aClosed = false; - let bClosed = false; - // Track active iterators so close() can call .return() on them - let aReadableIterator = null; - let bReadableIterator = null; - - const channelA = { - __proto__: null, - get writer() { return aWriter; }, - // Wrap readable to track the iterator for cleanup on close() - get readable() { - return { - __proto__: null, - [SymbolAsyncIterator]() { - const iter = aReadable[SymbolAsyncIterator](); - aReadableIterator = iter; - return iter; - }, - }; - }, - async close() { - if (aClosed) return; - aClosed = true; - // End the writer (signals end-of-stream to B's readable) - aWriter.endSync(); - // Stop iteration of this channel's readable - if (aReadableIterator?.return) { - await aReadableIterator.return(); - aReadableIterator = null; - } - }, - [SymbolAsyncDispose]() { - return this.close(); - }, - }; - - const channelB = { - __proto__: null, - get writer() { return bWriter; }, - get readable() { - return { - __proto__: null, - [SymbolAsyncIterator]() { - const iter = bReadable[SymbolAsyncIterator](); - bReadableIterator = iter; - return iter; - }, - }; - }, - async close() { - if (bClosed) return; - bClosed = true; - bWriter.endSync(); - if (bReadableIterator?.return) { - await bReadableIterator.return(); - bReadableIterator = null; - } - }, - [SymbolAsyncDispose]() { - return this.close(); - }, - }; + const channelA = createDuplexChannel(aWriter, aReadable); + const channelB = createDuplexChannel(bWriter, bReadable); // Signal handler: fail both writers with the abort reason so consumers // see the error. This is an error-path shutdown, not a clean close. @@ -132,6 +73,38 @@ function duplex(options = { __proto__: null }) { return [channelA, channelB]; } +function createDuplexChannel(writer, readable) { + // 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](); + let closePromise; + + return { + __proto__: null, + get writer() { return writer; }, + get readable() { return readable; }, + close() { + closePromise ??= closeDuplexChannel(writer, closeIterator); + return closePromise; + }, + [SymbolAsyncDispose]() { + return this.close(); + }, + }; +} + +async function closeDuplexChannel(writer, closeIterator) { + const result = writer.endSync(); + const endPromise = result < 0 ? writer.end() : undefined; + const returnPromise = closeIterator.return(); + + if (endPromise !== undefined) { + await SafePromiseAllReturnVoid([endPromise, returnPromise]); + } else { + await returnPromise; + } +} + module.exports = { duplex, }; diff --git a/test/parallel/test-stream-iter-duplex.js b/test/parallel/test-stream-iter-duplex.js index 0969b91e7d21..5d617baa9882 100644 --- a/test/parallel/test-stream-iter-duplex.js +++ b/test/parallel/test-stream-iter-duplex.js @@ -14,32 +14,26 @@ async function testBasicDuplex() { // A writes, B reads await channelA.writer.write('hello from A'); - await channelA.close(); - + const closing = channelA.close(); const dataAtB = await text(channelB.readable); + await closing; assert.strictEqual(dataAtB, 'hello from A'); } async function testBidirectional() { const [channelA, channelB] = duplex(); - // A writes to B, B writes to A concurrently - const writeA = (async () => { - await channelA.writer.write('A to B'); - await channelA.close(); - })(); - - const writeB = (async () => { - await channelB.writer.write('B to A'); - await channelB.close(); - })(); - - const readAtB = text(channelB.readable); - const readAtA = text(channelA.readable); + await channelA.writer.write('A to B'); + await channelB.writer.write('B to A'); - await Promise.all([writeA, writeB]); - - const [dataAtA, dataAtB] = await Promise.all([readAtA, readAtB]); + const endA = channelA.writer.end(); + const endB = channelB.writer.end(); + const [dataAtA, dataAtB] = await Promise.all([ + text(channelA.readable), + text(channelB.readable), + ]); + await Promise.all([endA, endB]); + await Promise.all([channelA.close(), channelB.close()]); assert.strictEqual(dataAtB, 'A to B'); assert.strictEqual(dataAtA, 'B to A'); @@ -51,19 +45,29 @@ async function testMultipleWrites() { await channelA.writer.write('one'); await channelA.writer.write('two'); await channelA.writer.write('three'); - await channelA.close(); - + const closing = channelA.close(); const data = await text(channelB.readable); + await closing; assert.strictEqual(data, 'onetwothree'); } async function testChannelClose() { const [channelA, channelB] = duplex(); - - await channelA.close(); - - // Should be able to close twice without error - await channelA.close(); + const iteratorA = channelA.readable[Symbol.asyncIterator](); + const otherIteratorA = channelA.readable[Symbol.asyncIterator](); + const pendingRead = iteratorA.next(); + + const closing = channelA.close(); + assert.strictEqual(channelA.close(), closing); + await closing; + + assert.strictEqual((await pendingRead).done, true); + assert.strictEqual((await otherIteratorA.next()).done, true); + assert.strictEqual( + (await channelA.readable[Symbol.asyncIterator]().next()).done, true); + await assert.rejects(channelB.writer.write('late'), { + code: 'ERR_INVALID_STATE', + }); // B's readable should end (A -> B direction is closed) const batches = []; @@ -80,9 +84,9 @@ async function testWithOptions() { }); await channelA.writer.write('msg'); - await channelA.close(); - + const closing = channelA.close(); const data = await text(channelB.readable); + await closing; assert.strictEqual(data, 'msg'); } @@ -95,15 +99,17 @@ async function testPerChannelOptions() { // Channel A -> B direction uses A's options // Channel B -> A direction uses B's options await channelA.writer.write('from-a'); - await channelA.close(); - await channelB.writer.write('from-b'); - await channelB.close(); + + const endA = channelA.writer.end(); + const endB = channelB.writer.end(); const [dataAtA, dataAtB] = await Promise.all([ text(channelA.readable), text(channelB.readable), ]); + await Promise.all([endA, endB]); + await Promise.all([channelA.close(), channelB.close()]); assert.strictEqual(dataAtB, 'from-a'); assert.strictEqual(dataAtA, 'from-b'); @@ -146,17 +152,39 @@ async function testWriterEndWithPreAbortedSignal() { async function testEmptyDuplex() { const [channelA, channelB] = duplex(); - // Close without writing - await channelA.close(); - await channelB.close(); + await channelA.writer.end(); + await channelB.writer.end(); const dataAtA = await bytes(channelA.readable); const dataAtB = await bytes(channelB.readable); + await Promise.all([channelA.close(), channelB.close()]); assert.strictEqual(dataAtA.byteLength, 0); assert.strictEqual(dataAtB.byteLength, 0); } +async function testCloseWaitsForDrain() { + const [channelA, channelB] = duplex(); + await channelA.writer.write('buffered'); + + let closed = false; + const closing = channelA.close().then(common.mustCall(() => { + closed = true; + })); + await new Promise(setImmediate); + assert.strictEqual(closed, false); + + assert.strictEqual(await text(channelB.readable), 'buffered'); + await closing; +} + +async function testClosePropagatesWriterFailure() { + const [channelA] = duplex(); + const reason = new Error('writer failed'); + channelA.writer.fail(reason); + await assert.rejects(channelA.close(), (error) => error === reason); +} + // Channel fail propagation async function testChannelFail() { const [a, b] = duplex(); @@ -200,6 +228,8 @@ Promise.all([ testAbortSignal(), testWriterEndWithPreAbortedSignal(), testEmptyDuplex(), + testCloseWaitsForDrain(), + testClosePropagatesWriterFailure(), testChannelFail(), testAbortSignalBothChannels(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-validation.js b/test/parallel/test-stream-iter-validation.js index 8dcfb46f173f..b93e6575490f 100644 --- a/test/parallel/test-stream-iter-validation.js +++ b/test/parallel/test-stream-iter-validation.js @@ -99,16 +99,16 @@ assert.throws(() => duplex({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' }); const [a, b] = duplex({ budget: Number.MAX_SAFE_INTEGER }); assert.strictEqual(a.writer.canWrite, true); assert.strictEqual(b.writer.canWrite, true); - a.close(); - b.close(); + a.writer.endSync(); + b.writer.endSync(); } // Per-direction overrides { const [a, b] = duplex({ a: { budget: 16384 }, b: { budget: 32768 } }); assert.strictEqual(a.writer.canWrite, true); assert.strictEqual(b.writer.canWrite, true); - a.close(); - b.close(); + a.writer.endSync(); + b.writer.endSync(); } assert.throws(() => duplex({ signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' }); @@ -397,8 +397,8 @@ async function testAsyncValidation() { // Duplex with valid options { const [a, b] = duplex({ budget: 16384 }); - a.close(); - b.close(); + a.writer.endSync(); + b.writer.endSync(); } // Broadcast with valid options From 2ac24820095734ddc631a40275cd083a165d5f41 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 00:39:33 +0000 Subject: [PATCH 07/23] stream: fix merge settlement tagging and falsy error tracking Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/consumers.js | 55 ++++---- .../test-stream-iter-consumers-merge.js | 124 ++++++++++++++++-- 2 files changed, 143 insertions(+), 36 deletions(-) diff --git a/lib/internal/streams/iter/consumers.js b/lib/internal/streams/iter/consumers.js index 30f4eb4f53da..05e159f7e4dc 100644 --- a/lib/internal/streams/iter/consumers.js +++ b/lib/internal/streams/iter/consumers.js @@ -401,6 +401,8 @@ function ondrain(drainable) { // Merge Utility // ============================================================================= +const kNoMergeError = { __proto__: null }; + /** * Merge multiple async iterables by yielding values in temporal order. * @param {...(AsyncIterable|object)} args @@ -451,6 +453,7 @@ function merge(...args) { let activeCount = normalized.length; let waitResolve = null; let onAbort; + let stopped = false; if (signal) { onAbort = () => { @@ -468,11 +471,13 @@ function merge(...args) { // Called when a source's .next() settles. Pushes the result into // the ready queue and wakes the consumer if it's waiting. const onSettled = (iterator, result) => { + if (stopped) return; if (result.done) { activeCount--; } else { ArrayPrototypePush(ready, { __proto__: null, + kind: 'value', iterator, value: result.value, }); @@ -483,6 +488,19 @@ function merge(...args) { } }; + const onRejected = (reason) => { + if (stopped) return; + ArrayPrototypePush(ready, { + __proto__: null, + kind: 'error', + reason, + }); + if (waitResolve) { + waitResolve(); + waitResolve = null; + } + }; + // Start one .next() per source const iterators = []; for (let i = 0; i < normalized.length; i++) { @@ -491,17 +509,12 @@ function merge(...args) { PromisePrototypeThen( iterator.next(), (r) => onSettled(iterator, r), - (err) => { - ArrayPrototypePush(ready, { __proto__: null, error: err }); - if (waitResolve) { - waitResolve(); - waitResolve = null; - } - }, + onRejected, ); } - let primaryError; + let completed = false; + let primaryError = kNoMergeError; try { while (activeCount > 0 || ready.length > 0) { signal?.throwIfAborted(); @@ -509,20 +522,14 @@ function merge(...args) { // Drain ready queue synchronously while (ready.length > 0) { const item = ArrayPrototypeShift(ready); - if (item?.error) { - throw item.error; + if (item.kind === 'error') { + throw item.reason; } yield item.value; PromisePrototypeThen( item.iterator.next(), (r) => onSettled(item.iterator, r), - (err) => { - ArrayPrototypePush(ready, { __proto__: null, error: err }); - if (waitResolve) { - waitResolve(); - waitResolve = null; - } - }, + onRejected, ); } @@ -537,9 +544,11 @@ function merge(...args) { }); } } + completed = true; } catch (err) { primaryError = err; } finally { + stopped = true; if (onAbort !== undefined) { signal.removeEventListener('abort', onAbort); } @@ -549,7 +558,7 @@ function merge(...args) { await cleanupIterators( iterators, primaryError, - signal?.aborted && primaryError === signal.reason, + !completed, ); } }, @@ -557,7 +566,7 @@ function merge(...args) { } async function cleanupIterators(iterators, primaryError, skipAwaitCleanup) { - let cleanupError; + let cleanupError = kNoMergeError; await SafePromiseAllReturnVoid(iterators, async (iterator) => { if (iterator.return) { try { @@ -569,12 +578,12 @@ async function cleanupIterators(iterators, primaryError, skipAwaitCleanup) { } } catch (err) { // Keep the first cleanup error encountered. - cleanupError ??= err; + if (cleanupError === kNoMergeError) cleanupError = err; } } }); - if (cleanupError !== undefined) { - if (primaryError !== undefined) { + if (cleanupError !== kNoMergeError) { + if (primaryError !== kNoMergeError) { // Both a primary error and a cleanup error occurred. // Wrap in SuppressedError so neither is lost: // .error = primaryError, .suppressed = cleanupError. @@ -584,7 +593,7 @@ async function cleanupIterators(iterators, primaryError, skipAwaitCleanup) { // No primary error - the cleanup error is the only error. throw cleanupError; } - if (primaryError !== undefined) { + if (primaryError !== kNoMergeError) { throw primaryError; } } diff --git a/test/parallel/test-stream-iter-consumers-merge.js b/test/parallel/test-stream-iter-consumers-merge.js index 84aeb24b6159..e5d20d2dffa3 100644 --- a/test/parallel/test-stream-iter-consumers-merge.js +++ b/test/parallel/test-stream-iter-consumers-merge.js @@ -108,6 +108,109 @@ async function testMergeSourceError() { ); } +async function testMergeFalsySourceErrors() { + const reasons = [undefined, null, false, 0, '', NaN]; + + for (const reason of reasons) { + const noError = { __proto__: null }; + let actual = noError; + try { + await text(merge(rejectedSource(reason), from('other'))); + } catch (error) { + actual = error; + } + assert.strictEqual(Object.is(actual, reason), true); + } +} + +function rejectedSource(reason) { + return { + __proto__: null, + [Symbol.asyncIterator]() { + return this; + }, + next() { + return Promise.reject(reason); + }, + }; +} + +function pendingSource() { + return { + __proto__: null, + [Symbol.asyncIterator]() { + return this; + }, + next() { + return new Promise(() => {}); + }, + return() { + return new Promise(() => {}); + }, + }; +} + +async function testMergeSourceErrorDoesNotAwaitCleanup() { + const reason = new Error('source failed'); + + const timedOut = { __proto__: null }; + const outcome = await Promise.race([ + text(merge(rejectedSource(reason), pendingSource())).then( + () => ({ __proto__: null, status: 'fulfilled' }), + (error) => ({ __proto__: null, status: 'rejected', error }), + ), + new Promise((resolve) => setImmediate(resolve, timedOut)), + ]); + + assert.notStrictEqual(outcome, timedOut); + assert.strictEqual(outcome.status, 'rejected'); + assert.strictEqual(outcome.error, reason); +} + +async function testMergeBreakDoesNotAwaitCleanup() { + async function* readySource() { + yield [Uint8Array.of(1)]; + } + + const timedOut = { __proto__: null }; + const outcome = await Promise.race([ + (async () => { + for await (const batch of merge(readySource(), pendingSource())) { + assert.deepStrictEqual(batch, [Uint8Array.of(1)]); + break; + } + return true; + })(), + new Promise((resolve) => setImmediate(resolve, timedOut)), + ]); + + assert.strictEqual(outcome, true); +} + +async function testMergeNaNAbortDoesNotAwaitCleanup() { + const ac = new AbortController(); + const iterator = merge(pendingSource(), pendingSource(), { + __proto__: null, + signal: ac.signal, + })[Symbol.asyncIterator](); + const next = iterator.next(); + await new Promise(setImmediate); + ac.abort(NaN); + + const timedOut = { __proto__: null }; + const outcome = await Promise.race([ + next.then( + () => ({ __proto__: null, status: 'fulfilled' }), + (error) => ({ __proto__: null, status: 'rejected', error }), + ), + new Promise((resolve) => setImmediate(resolve, timedOut)), + ]); + + assert.notStrictEqual(outcome, timedOut); + assert.strictEqual(outcome.status, 'rejected'); + assert.strictEqual(Object.is(outcome.error, NaN), true); +} + async function testMergeConsumerBreak() { let source1Return = false; let source2Return = false; @@ -296,9 +399,8 @@ async function testMergeCleanupErrorOnly() { ); } -// Primary error + cleanup error: a source throws during iteration AND -// iterator.return() also throws. Should get a SuppressedError. -async function testMergePrimaryAndCleanupError() { +// A primary source error must not wait for asynchronous cleanup failures. +async function testMergePrimaryErrorPrecedesCleanupError() { async function* badSource() { yield [new TextEncoder().encode('x')]; throw new Error('primary boom'); @@ -319,15 +421,7 @@ async function testMergePrimaryAndCleanupError() { // Consume until error } }, - (err) => { - assert.ok( - err instanceof SuppressedError, - `Expected SuppressedError, got ${err.constructor.name}`, - ); - assert.strictEqual(err.error.message, 'primary boom'); - assert.strictEqual(err.suppressed.message, 'cleanup boom'); - return true; - }, + { message: 'primary boom' }, ); } @@ -360,6 +454,10 @@ Promise.all([ testMergeWithAbortSignal(), testMergeSyncSources(), testMergeSourceError(), + testMergeFalsySourceErrors(), + testMergeSourceErrorDoesNotAwaitCleanup(), + testMergeBreakDoesNotAwaitCleanup(), + testMergeNaNAbortDoesNotAwaitCleanup(), testMergeConsumerBreak(), testMergeSignalMidIteration(), testMergeSignalDuringPendingMultiSourceRead(), @@ -368,6 +466,6 @@ Promise.all([ testMergeStringSources(), testMergeObjectLikeSources(), testMergeCleanupErrorOnly(), - testMergePrimaryAndCleanupError(), + testMergePrimaryErrorPrecedesCleanupError(), testMergeBreakWithCleanupError(), ]).then(common.mustCall()); From 9ccd8aec99989d2c5be220f55e25a6b1944032a4 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 00:47:30 +0000 Subject: [PATCH 08/23] stream: cancel active stream/iter pulls Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/share.js | 52 ++++++++--- test/parallel/test-stream-iter-share-async.js | 87 +++++++++++++++++++ 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/lib/internal/streams/iter/share.js b/lib/internal/streams/iter/share.js index 2cb4ed7fa469..00d9d387ce82 100644 --- a/lib/internal/streams/iter/share.js +++ b/lib/internal/streams/iter/share.js @@ -10,7 +10,9 @@ const { PromisePrototypeThen, PromiseResolve, PromiseWithResolvers, + SafePromiseRace, SafeSet, + Symbol, SymbolAsyncIterator, SymbolDispose, SymbolIterator, @@ -66,6 +68,9 @@ const { // Async Share Implementation // ============================================================================= +const kNoShareError = Symbol('kNoShareError'); +const kShareCancelled = Symbol('kShareCancelled'); + class ShareImpl { #source; #options; @@ -78,6 +83,9 @@ class ShareImpl { #cancelled = false; #pulling = false; #pullWaiters = []; + #cancelPromise; + #resolveCancel; + #cancelError = kNoShareError; #cachedMinCursor = 0; #cachedMinCursorConsumers = 0; /** Cumulative byte size of buffered entries */ @@ -86,6 +94,9 @@ class ShareImpl { constructor(source, options) { this.#source = source; this.#options = options; + const { promise, resolve } = PromiseWithResolvers(); + this.#cancelPromise = promise; + this.#resolveCancel = resolve; } get consumerCount() { @@ -124,6 +135,7 @@ class ShareImpl { resolve: null, reject: null, detached: false, + error: kNoShareError, pendingNext: PromiseResolve(), }; @@ -142,25 +154,21 @@ class ShareImpl { __proto__: null, [SymbolAsyncIterator]() { const getNext = async () => { - if (self.#sourceError !== undefined) { - state.detached = true; - self.#consumers.delete(state); - throw self.#sourceError; - } - // Loop until we get data, source is exhausted, or // consumer is detached. Multiple consumers may be woken // after a single pull - those that find no data at their // cursor must re-pull rather than terminating prematurely. for (;;) { if (state.detached) { - if (self.#sourceError !== undefined) throw self.#sourceError; + if (state.error !== kNoShareError) throw state.error; return { __proto__: null, done: true, value: undefined }; } if (self.#cancelled) { state.detached = true; + state.error = self.#cancelError; self.#deleteConsumer(state); + if (state.error !== kNoShareError) throw state.error; return { __proto__: null, done: true, value: undefined }; } @@ -180,7 +188,10 @@ class ShareImpl { if (self.#sourceExhausted) { state.detached = true; self.#deleteConsumer(state); - if (self.#sourceError !== undefined) throw self.#sourceError; + if (self.#sourceError !== undefined) { + state.error = self.#sourceError; + throw state.error; + } return { __proto__: null, done: true, value: undefined }; } @@ -188,8 +199,9 @@ class ShareImpl { const shouldBuffer = await self.#waitForBufferSpace(); if (shouldBuffer === null) { state.detached = true; + state.error = self.#cancelError; self.#deleteConsumer(state); - if (self.#sourceError !== undefined) throw self.#sourceError; + if (state.error !== kNoShareError) throw state.error; return { __proto__: null, done: true, value: undefined }; } @@ -241,14 +253,23 @@ class ShareImpl { this.#cancelled = true; if (reason !== undefined) { - this.#sourceError = reason; + this.#cancelError = reason; } + this.#resolveCancel(kShareCancelled); + this.#resolveCancel = null; + if (this.#sourceIterator?.return) { - PromisePrototypeThen(this.#sourceIterator.return(), undefined, () => {}); + try { + PromisePrototypeThen( + PromiseResolve(this.#sourceIterator.return()), undefined, () => {}); + } catch { + // Cancellation has precedence over source cleanup errors. + } } for (const consumer of this.#consumers) { + consumer.error = this.#cancelError; if (consumer.resolve) { if (reason !== undefined) { consumer.reject?.(reason); @@ -261,6 +282,8 @@ class ShareImpl { consumer.detached = true; } this.#consumers.clear(); + this.#buffer.clear(); + this.#bufferedBytes = 0; for (let i = 0; i < this.#pullWaiters.length; i++) { this.#pullWaiters[i](); @@ -364,7 +387,12 @@ class ShareImpl { } } - const result = await this.#sourceIterator.next(); + const result = await SafePromiseRace([ + this.#sourceIterator.next(), + this.#cancelPromise, + ]); + + if (this.#cancelled || result === kShareCancelled) return; if (result.done) { this.#sourceExhausted = true; diff --git a/test/parallel/test-stream-iter-share-async.js b/test/parallel/test-stream-iter-share-async.js index c96a0cb0f3c3..dafb157a7512 100644 --- a/test/parallel/test-stream-iter-share-async.js +++ b/test/parallel/test-stream-iter-share-async.js @@ -143,6 +143,70 @@ async function testShareCancelWithFalsyReason() { } } +async function testShareCancelWhileSourcePullPending() { + const noReason = { __proto__: null }; + + for (const reason of [noReason, 0]) { + const sourceStarted = Promise.withResolvers(); + const sourceNext = Promise.withResolvers(); + let nextCalls = 0; + let returnCalls = 0; + const source = { + __proto__: null, + [Symbol.asyncIterator]() { + return { + __proto__: null, + next() { + nextCalls++; + sourceStarted.resolve(); + return sourceNext.promise; + }, + async return() { + returnCalls++; + return { __proto__: null, done: true, value: undefined }; + }, + }; + }, + }; + const shared = share(source); + const iterator = shared.pull()[Symbol.asyncIterator](); + const read = iterator.next().then( + (value) => ({ __proto__: null, rejected: false, value }), + (error) => ({ __proto__: null, rejected: true, error }), + ); + + await sourceStarted.promise; + if (reason === noReason) { + shared.cancel(); + } else { + shared.cancel(reason); + } + + const timedOut = { __proto__: null }; + const outcome = await Promise.race([ + read, + new Promise((resolve) => setImmediate(resolve, timedOut)), + ]); + assert.notStrictEqual(outcome, timedOut); + if (reason === noReason) { + assert.strictEqual(outcome.rejected, false); + assert.strictEqual(outcome.value.done, true); + } else { + assert.strictEqual(outcome.rejected, true); + assert.strictEqual(outcome.error, reason); + } + assert.strictEqual(nextCalls, 1); + + sourceNext.resolve({ + __proto__: null, + done: false, + value: [Uint8Array.of(1)], + }); + await new Promise(setImmediate); + assert.strictEqual(returnCalls, 1); + } +} + async function testShareAbortSignal() { const ac = new AbortController(); const reason = new Error('share aborted'); @@ -272,6 +336,27 @@ async function testShareSourceError() { }, { message: 'share source boom' }); } +async function testShareSourceErrorFollowsBufferedData() { + const reason = new Error('share source boom'); + async function* failingSource() { + yield [Uint8Array.of(1)]; + throw reason; + } + + const shared = share(failingSource()); + const fast = shared.pull()[Symbol.asyncIterator](); + const slow = shared.pull()[Symbol.asyncIterator](); + const returned = shared.pull()[Symbol.asyncIterator](); + await returned.return(); + + assert.deepStrictEqual((await fast.next()).value, [Uint8Array.of(1)]); + await assert.rejects(fast.next(), (error) => error === reason); + + assert.deepStrictEqual((await slow.next()).value, [Uint8Array.of(1)]); + await assert.rejects(slow.next(), (error) => error === reason); + assert.strictEqual((await returned.next()).done, true); +} + async function testShareLateJoiningConsumer() { // A consumer that joins after some data has been consumed should only // see data remaining in the buffer (not items already trimmed). @@ -380,12 +465,14 @@ Promise.all([ testShareCancelMidIteration(), testShareCancelWithReason(), testShareCancelWithFalsyReason(), + testShareCancelWhileSourcePullPending(), testShareAbortSignal(), testShareAbortSignalWhileSourcePullPending(), testSharePullAbortSignalRejectsPendingNext(), testSharePullPreAbortedSignalDoesNotAddConsumer(), testShareAlreadyAborted(), testShareSourceError(), + testShareSourceErrorFollowsBufferedData(), testShareLateJoiningConsumer(), testShareConsumerBreak(), testShareMultipleConsumersConcurrentPull(), From 5ca0201c7cc29eba1a17b81103ee834ace9b57b7 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 00:54:14 +0000 Subject: [PATCH 09/23] stream: ensure pipeToSync requires synchronous close Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/pull.js | 15 +++++++--- test/parallel/test-stream-iter-pipeto-edge.js | 30 ++++++++++++------- test/parallel/test-stream-iter-pipeto.js | 5 +++- .../test-stream-iter-resizable-buffers.js | 3 +- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index a9a77377a84e..c1e42f03dc38 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -24,6 +24,7 @@ const { codes: { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, + ERR_INVALID_STATE, ERR_OUT_OF_RANGE, }, } = require('internal/errors'); @@ -981,6 +982,13 @@ function pullWithConsumerCleanup(source, transforms, signal) { */ function pipeToSync(source, ...args) { const { transforms, writer, options } = parsePipeToArgs(args, 'writeSync'); + const hasWritevSync = typeof writer.writevSync === 'function'; + const endSync = writer.endSync; + + if (!options?.preventClose && typeof endSync !== 'function') { + throw new ERR_INVALID_ARG_TYPE( + 'writer.endSync', 'Function', endSync); + } // Normalize source and create pipeline const normalized = fromSync(source); @@ -989,8 +997,6 @@ function pipeToSync(source, ...args) { normalized; let totalBytes = 0; - const hasWritevSync = typeof writer.writevSync === 'function'; - const hasEndSync = typeof writer.endSync === 'function'; try { for (const batch of pipeline) { @@ -1019,8 +1025,9 @@ function pipeToSync(source, ...args) { } if (!options?.preventClose) { - if (!hasEndSync || writer.endSync() < 0) { - writer.end?.(); + if (FunctionPrototypeCall(endSync, writer) < 0) { + throw new ERR_INVALID_STATE( + 'Writer could not be closed synchronously'); } } } catch (error) { diff --git a/test/parallel/test-stream-iter-pipeto-edge.js b/test/parallel/test-stream-iter-pipeto-edge.js index 3f09c4dfd42d..13223a226a1f 100644 --- a/test/parallel/test-stream-iter-pipeto-edge.js +++ b/test/parallel/test-stream-iter-pipeto-edge.js @@ -1,33 +1,41 @@ // Flags: --experimental-stream-iter 'use strict'; -// Edge case tests for pipeToSync: endSync fallback, preventFail. +// Edge case tests for pipeToSync close and failure behavior. const common = require('../common'); const assert = require('assert'); const { pipeToSync, fromSync } = require('stream/iter'); -// pipeToSync endSync returns negative → falls back to end() -async function testPipeToSyncEndSyncFallback() { +// pipeToSync cannot complete when endSync() requires async fallback. +async function testPipeToSyncEndSyncFailure() { let endCalled = false; const writer = { writeSync() { return true; }, - endSync() { return -1; }, // Negative → triggers end() fallback + endSync() { return -1; }, end() { endCalled = true; }, }; - pipeToSync(fromSync('data'), writer); - assert.strictEqual(endCalled, true); + assert.throws( + () => pipeToSync(fromSync('data'), writer, { preventFail: true }), + { code: 'ERR_INVALID_STATE' }, + ); + assert.strictEqual(endCalled, false); } -// pipeToSync endSync missing → falls back to end() +// pipeToSync requires endSync() when closing is enabled. async function testPipeToSyncNoEndSync() { + let writeCalled = false; let endCalled = false; const writer = { - writeSync() { return true; }, + writeSync() { writeCalled = true; return true; }, end() { endCalled = true; }, }; - pipeToSync(fromSync('data'), writer); - assert.strictEqual(endCalled, true); + assert.throws( + () => pipeToSync(fromSync('data'), writer), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + assert.strictEqual(writeCalled, false); + assert.strictEqual(endCalled, false); } // pipeToSync with preventFail: true — source error does NOT call fail() @@ -61,7 +69,7 @@ async function testPipeToSyncPreventClose() { } Promise.all([ - testPipeToSyncEndSyncFallback(), + testPipeToSyncEndSyncFailure(), testPipeToSyncNoEndSync(), testPipeToSyncPreventFail(), testPipeToSyncPreventClose(), diff --git a/test/parallel/test-stream-iter-pipeto.js b/test/parallel/test-stream-iter-pipeto.js index 5d8b5088f540..f16d7ae89972 100644 --- a/test/parallel/test-stream-iter-pipeto.js +++ b/test/parallel/test-stream-iter-pipeto.js @@ -71,6 +71,7 @@ async function testPipeToSyncSourceError() { let failCalled = false; const writer = { writeSync() { return true; }, + endSync: common.mustNotCall(), fail(reason) { failCalled = true; }, }; function* failingSource() { @@ -127,6 +128,7 @@ async function testPipeToSyncWithTransforms() { const chunks = []; const writer = { writeSync(chunk) { chunks.push(new TextDecoder().decode(chunk)); return true; }, + endSync() { return 0; }, }; const upper = (batch) => { if (batch === null) return null; @@ -160,6 +162,7 @@ async function testPipeToSyncWriterTransformMethodIgnored() { chunks.push(new TextDecoder().decode(chunk)); return true; }, + endSync() { return 0; }, }; pipeToSync(fromSync('hello'), writer); @@ -240,7 +243,7 @@ async function testPipeToSyncMinimalWriter() { }, }; - pipeToSync(fromSync('minimal-sync'), minimalWriter); + pipeToSync(fromSync('minimal-sync'), minimalWriter, { preventClose: true }); assert.strictEqual(chunks.length > 0, true); } diff --git a/test/parallel/test-stream-iter-resizable-buffers.js b/test/parallel/test-stream-iter-resizable-buffers.js index c4be26774ac6..b66973393cac 100644 --- a/test/parallel/test-stream-iter-resizable-buffers.js +++ b/test/parallel/test-stream-iter-resizable-buffers.js @@ -161,7 +161,8 @@ async function testPipeRejectsWriterResize() { fail: common.mustCall(), }; assert.throws( - () => pipeToSync([new Uint8Array(syncBuffer)], syncWriter), + () => pipeToSync( + [new Uint8Array(syncBuffer)], syncWriter, { preventClose: true }), kResizeError, ); } From 065692c18043239512c57d5a3bae26e252ea61a3 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 01:00:22 +0000 Subject: [PATCH 10/23] stream: replace object sentinel with symbol Signed-off-by: James M Snell --- lib/internal/streams/iter/consumers.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/internal/streams/iter/consumers.js b/lib/internal/streams/iter/consumers.js index 05e159f7e4dc..446fde1d88e3 100644 --- a/lib/internal/streams/iter/consumers.js +++ b/lib/internal/streams/iter/consumers.js @@ -18,6 +18,7 @@ const { Promise, PromisePrototypeThen, SafePromiseAllReturnVoid, + Symbol, SymbolAsyncIterator, TypedArrayPrototypeGetBuffer, TypedArrayPrototypeGetByteLength, @@ -401,7 +402,7 @@ function ondrain(drainable) { // Merge Utility // ============================================================================= -const kNoMergeError = { __proto__: null }; +const kNoMergeError = Symbol('kNoMergeError'); /** * Merge multiple async iterables by yielding values in temporal order. From 540f9ec8c89cf4b47f4655ca0a03f73b69de6025 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 19:54:01 +0000 Subject: [PATCH 11/23] stream: address stream/iter review feedback Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/consumers.js | 20 ++++++++++------- lib/internal/streams/iter/duplex.js | 7 +++++- lib/internal/streams/iter/push.js | 8 +++++++ .../test-stream-iter-consumers-merge.js | 22 +++++++++++++++++++ test/parallel/test-stream-iter-duplex.js | 15 +++++++++++++ 5 files changed, 63 insertions(+), 9 deletions(-) diff --git a/lib/internal/streams/iter/consumers.js b/lib/internal/streams/iter/consumers.js index 446fde1d88e3..0a317939e8f4 100644 --- a/lib/internal/streams/iter/consumers.js +++ b/lib/internal/streams/iter/consumers.js @@ -18,6 +18,7 @@ const { Promise, PromisePrototypeThen, SafePromiseAllReturnVoid, + SafeSet, Symbol, SymbolAsyncIterator, TypedArrayPrototypeGetBuffer, @@ -451,6 +452,7 @@ function merge(...args) { // async tick per batch. Each source has at most one pending .next() // at a time. Every batch from every source is preserved. const ready = []; + const pendingPulls = new SafeSet(); let activeCount = normalized.length; let waitResolve = null; let onAbort; @@ -472,6 +474,7 @@ function merge(...args) { // Called when a source's .next() settles. Pushes the result into // the ready queue and wakes the consumer if it's waiting. const onSettled = (iterator, result) => { + pendingPulls.delete(iterator); if (stopped) return; if (result.done) { activeCount--; @@ -489,7 +492,8 @@ function merge(...args) { } }; - const onRejected = (reason) => { + const onRejected = (iterator, reason) => { + pendingPulls.delete(iterator); if (stopped) return; ArrayPrototypePush(ready, { __proto__: null, @@ -507,14 +511,14 @@ function merge(...args) { for (let i = 0; i < normalized.length; i++) { const iterator = normalized[i][SymbolAsyncIterator](); ArrayPrototypePush(iterators, iterator); + pendingPulls.add(iterator); PromisePrototypeThen( iterator.next(), (r) => onSettled(iterator, r), - onRejected, + (reason) => onRejected(iterator, reason), ); } - let completed = false; let primaryError = kNoMergeError; try { while (activeCount > 0 || ready.length > 0) { @@ -527,10 +531,11 @@ function merge(...args) { throw item.reason; } yield item.value; + pendingPulls.add(item.iterator); PromisePrototypeThen( item.iterator.next(), (r) => onSettled(item.iterator, r), - onRejected, + (reason) => onRejected(item.iterator, reason), ); } @@ -545,7 +550,6 @@ function merge(...args) { }); } } - completed = true; } catch (err) { primaryError = err; } finally { @@ -559,20 +563,20 @@ function merge(...args) { await cleanupIterators( iterators, primaryError, - !completed, + pendingPulls, ); } }, }; } -async function cleanupIterators(iterators, primaryError, skipAwaitCleanup) { +async function cleanupIterators(iterators, primaryError, pendingPulls) { let cleanupError = kNoMergeError; await SafePromiseAllReturnVoid(iterators, async (iterator) => { if (iterator.return) { try { const result = iterator.return(); - if (skipAwaitCleanup) { + if (pendingPulls.has(iterator)) { markPromiseAsHandled(result); } else { await result; diff --git a/lib/internal/streams/iter/duplex.js b/lib/internal/streams/iter/duplex.js index 50a04961b1f2..a60c510c4199 100644 --- a/lib/internal/streams/iter/duplex.js +++ b/lib/internal/streams/iter/duplex.js @@ -12,6 +12,7 @@ const { } = primordials; const { + isConsumerReturnError, push, } = require('internal/streams/iter/push'); const { @@ -99,7 +100,11 @@ async function closeDuplexChannel(writer, closeIterator) { const returnPromise = closeIterator.return(); if (endPromise !== undefined) { - await SafePromiseAllReturnVoid([endPromise, returnPromise]); + try { + await SafePromiseAllReturnVoid([endPromise, returnPromise]); + } catch (error) { + if (!isConsumerReturnError(error)) throw error; + } } else { await returnPromise; } diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index 36a2034e5298..fa84cafdfde7 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -11,6 +11,7 @@ const { PromiseReject, PromiseResolve, PromiseWithResolvers, + SafeWeakSet, Symbol, SymbolAsyncDispose, SymbolAsyncIterator, @@ -55,6 +56,11 @@ const { } = require('internal/streams/iter/ringbuffer'); const kNoFailReason = Symbol('kNoFailReason'); +const consumerReturnErrors = new SafeWeakSet(); + +function isConsumerReturnError(error) { + return consumerReturnErrors.has(error); +} function raceEndWithSignal(promise, signal) { if (!signal) return promise; @@ -471,6 +477,7 @@ class PushQueue { if (this.#consumerState !== 'active') return; this.#consumerState = 'returned'; const error = new ERR_INVALID_STATE.TypeError('Stream closed by consumer'); + consumerReturnErrors.add(error); this.#terminateWriterFromConsumer(error); this.#resolvePendingReads(); // Resolve pending drains with false - no more data will be consumed @@ -791,5 +798,6 @@ function push(...args) { } module.exports = { + isConsumerReturnError, push, }; diff --git a/test/parallel/test-stream-iter-consumers-merge.js b/test/parallel/test-stream-iter-consumers-merge.js index e5d20d2dffa3..becf68ee63a1 100644 --- a/test/parallel/test-stream-iter-consumers-merge.js +++ b/test/parallel/test-stream-iter-consumers-merge.js @@ -447,6 +447,27 @@ async function testMergeBreakWithCleanupError() { ); } +async function testMergeMultiSourceBreakWithCleanupError() { + async function* failingReturnSource() { + try { + yield [new TextEncoder().encode('data')]; + } finally { + await Promise.resolve(); + throwInFinally('async cleanup on break'); + } + } + + await assert.rejects( + async () => { + // eslint-disable-next-line no-unused-vars + for await (const _ of merge(failingReturnSource(), from('other'))) { + break; + } + }, + { message: 'async cleanup on break' }, + ); +} + Promise.all([ testMergeTwoSources(), testMergeSingleSource(), @@ -468,4 +489,5 @@ Promise.all([ testMergeCleanupErrorOnly(), testMergePrimaryErrorPrecedesCleanupError(), testMergeBreakWithCleanupError(), + testMergeMultiSourceBreakWithCleanupError(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-duplex.js b/test/parallel/test-stream-iter-duplex.js index 5d617baa9882..354fa28b67a6 100644 --- a/test/parallel/test-stream-iter-duplex.js +++ b/test/parallel/test-stream-iter-duplex.js @@ -77,6 +77,20 @@ async function testChannelClose() { assert.strictEqual(batches.length, 0); } +async function testConcurrentChannelClose() { + const [channelA, channelB] = duplex(); + + const results = await Promise.allSettled([ + channelA.close(), + channelB.close(), + ]); + + assert.deepStrictEqual(results, [ + { status: 'fulfilled', value: undefined }, + { status: 'fulfilled', value: undefined }, + ]); +} + async function testWithOptions() { const [channelA, channelB] = duplex({ budget: 16384, @@ -223,6 +237,7 @@ Promise.all([ testBidirectional(), testMultipleWrites(), testChannelClose(), + testConcurrentChannelClose(), testWithOptions(), testPerChannelOptions(), testAbortSignal(), From b8332d29bbe3fd55a8a8ddeaca3bb84718fc2c23 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 01:12:34 +0000 Subject: [PATCH 12/23] stream: canWrite and ondrain now reflect physical capacity Signed-off-by: James M Snell Assisted-by: Opencode --- doc/api/stream_iter.md | 20 +++++++---- lib/internal/streams/iter/broadcast.js | 14 ++++---- lib/internal/streams/iter/push.js | 6 ++-- ...test-stream-iter-broadcast-backpressure.js | 33 ++++++++++++++++++- test/parallel/test-stream-iter-push-writer.js | 31 +++++++++++++++++ 5 files changed, 87 insertions(+), 17 deletions(-) diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index c1ebacd3dfad..6b430dd1d8e6 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -417,9 +417,13 @@ writer.fail(err); // Always synchronous, no fallback needed * {boolean|null} -Returns `true` if the next write is likely to be accepted (buffered data is -below capacity), `false` if backpressure is active, or `null` if the writer -is closed or the consumer has disconnected. +Returns `true` if the slots buffer has physical capacity (buffered data is +below the configured byte budget), `false` if the budget is exhausted, or +`null` if the writer is closed or the consumer has disconnected. + +This reports physical capacity independently of the backpressure policy. With +`'drop-oldest'` or `'drop-newest'`, writes still complete when this is `false` +by evicting buffered data or discarding the incoming data, respectively. This is a hint, not a guarantee: the state can change between the check and the write. Use [`ondrain()`][] to wait for capacity rather than polling. @@ -1089,9 +1093,13 @@ added: * `drainable` {Object} An object implementing the drainable protocol. * Returns: {Promise|null} -Wait for a drainable writer's backpressure to clear. Returns `null` if -the object does not implement the drainable protocol, or a promise that -fulfills with `true` when the writer can accept more data. +Wait for a drainable writer to regain physical buffer capacity. Returns `null` +if the object does not implement the drainable protocol, or a promise that +fulfills with `true` when buffered data falls below the byte budget. + +For writers using `'drop-oldest'` or `'drop-newest'`, this waits for physical +capacity even though writes do not block. This allows producers to avoid data +loss by waiting before writing. ```mjs import { push, ondrain, text } from 'node:stream/iter'; diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js index 0dc6364768d5..4131fbf2b8ec 100644 --- a/lib/internal/streams/iter/broadcast.js +++ b/lib/internal/streams/iter/broadcast.js @@ -310,6 +310,7 @@ class BroadcastImpl { if (this.#ended || this.#cancelled) return false; const batchSize = entry.byteLength; + let droppedOldest = false; // Skip empty chunks -- zero-byte writes would accumulate infinitely // without ever triggering backpressure under a byte-budget model. @@ -321,6 +322,7 @@ class BroadcastImpl { case 'unbounded': return false; case 'drop-oldest': + droppedOldest = true; while (this.#bufferedBytes >= this.#options.budget && this.#buffer.length > 0) { const evicted = this.#buffer.shift(); @@ -343,6 +345,10 @@ class BroadcastImpl { this.#buffer.push(entry); this.#bufferedBytes += batchSize; this.#notifyConsumers(); + if (droppedOldest && + this.#bufferedBytes < this.#options.budget) { + this[kOnBufferDrained]?.(); + } return true; } @@ -399,15 +405,13 @@ class BroadcastImpl { } /** - * Check if the next write is likely to be accepted. + * Check whether the slots buffer has capacity. * Returns null if ended/cancelled, true/false otherwise. * @returns {boolean | null} */ [kCanWrite]() { if (this.#ended || this.#cancelled) return null; - if ((this.#options.backpressure === 'strict' || - this.#options.backpressure === 'unbounded') && - this.#bufferedBytes >= this.#options.budget) { + if (this.#bufferedBytes >= this.#options.budget) { return false; } return true; @@ -666,7 +670,6 @@ class BroadcastWriter { writeSync(chunk) { if (this.#state !== 'open') return false; - if (!this.#broadcast[kCanWrite]()) return false; const converted = toUint8Array(chunk); const batch = createBatchEntry([converted]); @@ -680,7 +683,6 @@ class BroadcastWriter { writevSync(chunks) { validateArray(chunks, 'chunks'); if (this.#state !== 'open') return false; - if (!this.#broadcast[kCanWrite]()) return false; const converted = convertChunks(chunks); const batch = createBatchEntry(converted); if (this.#broadcast[kWrite](batch)) { diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index fa84cafdfde7..c53fc7e902c6 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -152,7 +152,7 @@ class PushQueue { // =========================================================================== /** - * Check if the next write is likely to be accepted. + * Check whether the slots buffer has capacity. * Returns null if writer is closed/errored or consumer has terminated. * @returns {boolean | null} */ @@ -160,9 +160,7 @@ class PushQueue { if (this.#writerState !== 'open' || this.#consumerState !== 'active') { return null; } - if ((this.#backpressure === 'strict' || - this.#backpressure === 'unbounded') && - this.#bufferedBytes >= this.#budget) { + if (this.#bufferedBytes >= this.#budget) { return false; } return true; diff --git a/test/parallel/test-stream-iter-broadcast-backpressure.js b/test/parallel/test-stream-iter-broadcast-backpressure.js index 698f1821aeea..6efa7eb54c73 100644 --- a/test/parallel/test-stream-iter-broadcast-backpressure.js +++ b/test/parallel/test-stream-iter-broadcast-backpressure.js @@ -3,7 +3,7 @@ const common = require('../common'); const assert = require('assert'); -const { broadcast, text } = require('stream/iter'); +const { broadcast, ondrain, text } = require('stream/iter'); // ============================================================================= // Backpressure policies @@ -47,6 +47,36 @@ async function testDropNewest() { assert.strictEqual(data, 'K'.repeat(16384)); } +async function testDropPoliciesReportPhysicalCapacity() { + const chunk = new Uint8Array(16384); + + for (const backpressure of ['drop-oldest', 'drop-newest']) { + const { writer, broadcast: bc } = broadcast({ + budget: chunk.byteLength, + backpressure, + }); + const iterator = bc.push()[Symbol.asyncIterator](); + + assert.strictEqual(writer.writeSync(chunk), true); + assert.strictEqual(writer.canWrite, false); + + let drained = false; + const drain = ondrain(writer); + drain.then(common.mustCall(() => { drained = true; })); + + // Drop policies still accept writes despite having no physical capacity. + assert.strictEqual(writer.writeSync(chunk), true); + assert.strictEqual(writer.canWrite, false); + await new Promise(setImmediate); + assert.strictEqual(drained, false); + + assert.strictEqual((await iterator.next()).done, false); + assert.strictEqual(await drain, true); + assert.strictEqual(writer.canWrite, true); + bc.cancel(); + } +} + // ============================================================================= // Block backpressure // ============================================================================= @@ -269,6 +299,7 @@ async function testEndSyncReturnValue() { Promise.all([ testDropOldest(), testDropNewest(), + testDropPoliciesReportPhysicalCapacity(), testBlockBackpressure(), testBlockBackpressureContent(), testStrictBackpressureOverflow(), diff --git a/test/parallel/test-stream-iter-push-writer.js b/test/parallel/test-stream-iter-push-writer.js index dd6cf7d494b2..4f47675da146 100644 --- a/test/parallel/test-stream-iter-push-writer.js +++ b/test/parallel/test-stream-iter-push-writer.js @@ -19,6 +19,36 @@ async function testOndrain() { assert.strictEqual(ondrain(writer), null); } +async function testDropPoliciesReportPhysicalCapacity() { + const chunk = new Uint8Array(16384); + + for (const backpressure of ['drop-oldest', 'drop-newest']) { + const { writer, readable } = push({ + budget: chunk.byteLength, + backpressure, + }); + const iterator = readable[Symbol.asyncIterator](); + + assert.strictEqual(writer.writeSync(chunk), true); + assert.strictEqual(writer.canWrite, false); + + let drained = false; + const drain = ondrain(writer); + drain.then(common.mustCall(() => { drained = true; })); + + // Drop policies still accept writes despite having no physical capacity. + assert.strictEqual(writer.writeSync(chunk), true); + assert.strictEqual(writer.canWrite, false); + await new Promise(setImmediate); + assert.strictEqual(drained, false); + + assert.strictEqual((await iterator.next()).done, false); + assert.strictEqual(await drain, true); + assert.strictEqual(writer.canWrite, true); + await iterator.return(); + } +} + async function testOndrainNonDrainable() { // Non-drainable objects return null assert.strictEqual(ondrain(null), null); @@ -586,6 +616,7 @@ async function testFailRejectsPendingReadWithFalsyReason() { Promise.all([ testOndrain(), + testDropPoliciesReportPhysicalCapacity(), testOndrainNonDrainable(), testWriteWithSignalRejects(), testWriteWithPreAbortedSignal(), From c5cce00ced056c0e44bf251dfe98cc6d9bd30496 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 01:23:12 +0000 Subject: [PATCH 13/23] stream: ensure factory signals remain active through closing Signed-off-by: James M Snell Assisted-by: Opencode --- doc/api/stream_iter.md | 4 +++- lib/internal/streams/iter/push.js | 3 ++- test/parallel/test-stream-iter-push-writer.js | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index 6b430dd1d8e6..6d6361472194 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -783,7 +783,9 @@ added: **Default:** `16384`. * `backpressure` {string} Backpressure policy: `'strict'`, `'unbounded'`, `'drop-oldest'`, or `'drop-newest'`. **Default:** `'strict'`. - * `signal` {AbortSignal} Abort the stream. + * `signal` {AbortSignal} Abort the stream. The signal remains active while + buffered data drains after `writer.end()`; aborting during that time fails + the writer and rejects the pending `end()` promise. * Returns: {Object} * `writer` {Writable} The writer side. * `readable` {AsyncIterable} whose chunks fulfill with {Uint8Array\[]} diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index c53fc7e902c6..8af326418b33 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -334,7 +334,6 @@ class PushQueue { return this.#bytesWritten; // Idempotent } - this.#cleanup(); this.#rejectPendingWrites( new ERR_INVALID_STATE.TypeError('Writer closed')); this.#resolvePendingDrains(false); @@ -342,6 +341,7 @@ class PushQueue { // If buffer is empty, close immediately if (this.#slots.length === 0) { this.#writerState = 'closed'; + this.#cleanup(); this.#resolvePendingReads(); return this.#bytesWritten; } @@ -359,6 +359,7 @@ class PushQueue { endDrained() { if (this.#writerState !== 'closing') return; this.#writerState = 'closed'; + this.#cleanup(); if (this.#pendingEnd) { this.#pendingEnd.resolve(this.#bytesWritten); this.#pendingEnd = null; diff --git a/test/parallel/test-stream-iter-push-writer.js b/test/parallel/test-stream-iter-push-writer.js index 4f47675da146..8d9109d9eb3e 100644 --- a/test/parallel/test-stream-iter-push-writer.js +++ b/test/parallel/test-stream-iter-push-writer.js @@ -324,6 +324,21 @@ async function testEndSignalAbortWhileDraining() { assert.strictEqual(await completedEnd, 5); } +async function testFactorySignalAbortWhileDraining() { + const controller = new AbortController(); + const reason = new Error('stream aborted while draining'); + const { writer, readable } = push({ signal: controller.signal }); + + writer.writeSync('hello'); + const end = writer.end(); + const endRejected = assert.rejects(end, (error) => error === reason); + controller.abort(reason); + + await endRejected; + await assert.rejects(text(readable), (error) => error === reason); + await assert.rejects(writer.end(), (error) => error === reason); +} + async function testEndAfterEndSyncWaitsForDrain() { const { writer, readable } = push(); writer.writeSync('hello'); @@ -634,6 +649,7 @@ Promise.all([ testEndAsyncReturnValue(), testEndWithPreAbortedSignal(), testEndSignalAbortWhileDraining(), + testFactorySignalAbortWhileDraining(), testEndAfterEndSyncWaitsForDrain(), testWriteUint8Array(), testOndrainWaitsForDrain(), From 79250c641b1d4d74dc07193ed19827f5ce474f28 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 01:27:42 +0000 Subject: [PATCH 14/23] stream: ensure async dispoal after endSync awaits for drain Signed-off-by: James M Snell Assisted-by: Opencode --- doc/api/stream_iter.md | 6 +++++ lib/internal/streams/iter/push.js | 25 ++++++------------- test/parallel/test-stream-iter-push-writer.js | 18 +++++++++++++ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index 6d6361472194..f8e27c3200e9 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -462,6 +462,12 @@ or errored, this is a no-op. Unlike `write()` and `end()`, `fail()` is unconditionally synchronous because failing a writer is a pure state transition with no async work to perform. +#### `writer[Symbol.asyncDispose]()` + +If the writer is open, calls `writer.fail()`. If the writer is closing after +`end()` or `endSync()`, waits for buffered data to drain. If the writer is +already closed or errored, resolves immediately. + #### `writer.write(chunk[, options])` * `chunk` {Uint8Array|string} diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index 8af326418b33..767a54681064 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -412,12 +412,12 @@ class PushQueue { return this.#writerState; } - get pendingEndPromise() { - return this.#pendingEnd?.promise ?? null; - } - - setPendingEnd(pending) { - this.#pendingEnd = pending; + getPendingEndPromise() { + if (this.#pendingEnd === null) { + const { promise, resolve, reject } = PromiseWithResolvers(); + this.#pendingEnd = { __proto__: null, promise, resolve, reject }; + } + return this.#pendingEnd.promise; } /** @@ -691,15 +691,7 @@ class PushWriter { return PromiseReject(this.#queue.error); } if (result === -3) { - // Closing: buffer has data, create deferred promise that resolves - // when consumer drains past the end sentinel - const pendingEndPromise = this.#queue.pendingEndPromise; - if (pendingEndPromise !== null) { - return raceEndWithSignal(pendingEndPromise, signal); - } - const { promise, resolve, reject } = PromiseWithResolvers(); - this.#queue.setPendingEnd({ __proto__: null, promise, resolve, reject }); - return raceEndWithSignal(promise, signal); + return raceEndWithSignal(this.#queue.getPendingEndPromise(), signal); } // >= 0: byte count (immediate close or idempotent) return PromiseResolve(result); @@ -719,8 +711,7 @@ class PushWriter { [SymbolAsyncDispose]() { const state = this.#queue.writerState; if (state === 'closing') { - // Wait for graceful drain - return this.#queue.pendingEndPromise ?? PromiseResolve(); + return this.#queue.getPendingEndPromise(); } if (state === 'open') { this.fail(); diff --git a/test/parallel/test-stream-iter-push-writer.js b/test/parallel/test-stream-iter-push-writer.js index 8d9109d9eb3e..2e4eeafbda50 100644 --- a/test/parallel/test-stream-iter-push-writer.js +++ b/test/parallel/test-stream-iter-push-writer.js @@ -561,6 +561,23 @@ async function testAsyncDispose() { } } +async function testAsyncDisposeWaitsAfterEndSync() { + const { writer, readable } = push({ budget: 16384 }); + writer.writeSync('hello'); + assert.strictEqual(writer.endSync(), -1); + + let disposed = false; + const disposal = writer[Symbol.asyncDispose]().then(() => { + disposed = true; + }); + await Promise.resolve(); + assert.strictEqual(disposed, false); + + assert.strictEqual(await text(readable), 'hello'); + await disposal; + assert.strictEqual(disposed, true); +} + async function testSyncDispose() { const { writer, readable } = push({ budget: 16384 }); writer.writeSync('hello'); @@ -666,5 +683,6 @@ Promise.all([ testEndIdempotentWhenClosed(), testEndRejectsWhenErrored(), testAsyncDispose(), + testAsyncDisposeWaitsAfterEndSync(), testSyncDispose(), ]).then(common.mustCall()); From 8917f004c69e6d4e0b5fb810339e739440de8fba Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 01:38:22 +0000 Subject: [PATCH 15/23] stream: ensure pre-existing writes drain before EOF and end() waits Signed-off-by: James M Snell Assisted-by: Opencode --- doc/api/stream_iter.md | 6 +- lib/internal/streams/iter/push.js | 35 +++++------ test/parallel/test-stream-iter-push-writer.js | 61 +++++++++++++------ 3 files changed, 65 insertions(+), 37 deletions(-) diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index f8e27c3200e9..b46057f60371 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -435,7 +435,11 @@ the write. Use [`ondrain()`][] to wait for capacity rather than polling. the pending `end()` call; it does not fail the writer itself. * Returns: {Promise} Fulfills with the total number of bytes written. -Signals that no more data will be written and waits for buffered data to drain. +Signals that no more data will be written. Writes already waiting for buffer +space remain ordered before the end of the stream, while later writes fail. If +data is outstanding, the returned promise fulfills after the consumer pulls +`done: true` beyond the final batch. If no data is buffered or pending, the +writer closes immediately. #### `writer.endSync()` diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index 767a54681064..8d680cd2ad01 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -299,7 +299,10 @@ class PushQueue { const onAbort = () => { // Remove from queue so it doesn't occupy a slot const idx = this.#pendingWrites.indexOf(entry); - if (idx !== -1) this.#pendingWrites.removeAt(idx); + if (idx !== -1) { + this.#pendingWrites.removeAt(idx); + this.#resolvePendingReads(); + } reject(signal.reason ?? lazyDOMException('Aborted', 'AbortError')); }; @@ -334,30 +337,31 @@ class PushQueue { return this.#bytesWritten; // Idempotent } - this.#rejectPendingWrites( - new ERR_INVALID_STATE.TypeError('Writer closed')); this.#resolvePendingDrains(false); - // If buffer is empty, close immediately - if (this.#slots.length === 0) { + // If there is no accepted work to drain, close immediately. + if (this.#slots.length === 0 && this.#pendingWrites.length === 0) { this.#writerState = 'closed'; this.#cleanup(); this.#resolvePendingReads(); return this.#bytesWritten; } - // Buffer has data: transition to closing, defer completion until drained + // Accepted work remains: close after it drains and the consumer pulls EOF. this.#writerState = 'closing'; return -3; // Signal to PushWriter: create deferred end promise } /** - * Called by the read path when the consumer has drained all data while - * the writer is in the 'closing' state. Transitions to 'closed' and - * resolves the pending end promise. + * Called when the consumer pulls past all accepted data while the writer is + * closing. Transitions to 'closed' and resolves the pending end promise. */ endDrained() { - if (this.#writerState !== 'closing') return; + if (this.#writerState !== 'closing' || + this.#slots.length > 0 || + this.#pendingWrites.length > 0) { + return; + } this.#writerState = 'closed'; this.#cleanup(); if (this.#pendingEnd) { @@ -384,6 +388,7 @@ class PushQueue { this.#cleanup(); this.#rejectPendingReads(this.#error); this.#rejectPendingDrains(this.#error); + this.#rejectPendingWrites(this.#error); if (wasClosing) { // Short-circuit the graceful drain: reject the pending end promise @@ -391,8 +396,6 @@ class PushQueue { this.#pendingEnd.reject(this.#error); this.#pendingEnd = null; } - } else { - this.#rejectPendingWrites(this.#error); } } @@ -446,10 +449,6 @@ class PushQueue { if (this.#slots.length > 0) { const result = this.#drain(); this.#resolvePendingWrites(); - // After draining, check if writer was closing and buffer is now empty - if (this.#writerState === 'closing' && this.#slots.length === 0) { - this.endDrained(); - } return { __proto__: null, done: false, value: result }; } @@ -555,7 +554,9 @@ class PushQueue { } catch (error) { pending.reject(error); } - } else if (this.#writerState === 'closing' && this.#slots.length === 0) { + } else if (this.#writerState === 'closing' && + this.#slots.length === 0 && + this.#pendingWrites.length === 0) { this.endDrained(); const pending = this.#pendingReads.shift(); pending.resolve({ __proto__: null, done: true, value: undefined }); diff --git a/test/parallel/test-stream-iter-push-writer.js b/test/parallel/test-stream-iter-push-writer.js index 2e4eeafbda50..98f084ab01a6 100644 --- a/test/parallel/test-stream-iter-push-writer.js +++ b/test/parallel/test-stream-iter-push-writer.js @@ -84,9 +84,9 @@ async function testWriteWithSignalRejects() { await assert.rejects(writePromise, { name: 'AbortError' }); // Clean up - writer.end(); - // eslint-disable-next-line no-unused-vars - for await (const _ of readable) { break; } + const end = writer.end(); + await text(readable); + await end; } async function testWriteWithPreAbortedSignal() { @@ -100,9 +100,10 @@ async function testWriteWithPreAbortedSignal() { // Writer should still be usable for other writes writer.write('ok'); - writer.end(); + const end = writer.end(); const data = await text(readable); assert.strictEqual(data, 'ok'); + await end; } async function testCancelledWriteRemovedFromQueue() { @@ -127,7 +128,7 @@ async function testCancelledWriteRemovedFromQueue() { // The cancelled write should NOT occupy a pending slot. // A new write should succeed now that the buffer has room. await writer.write(kChunk); - writer.end(); + const end = writer.end(); const result = await iter.next(); assert.ok(!result.done); @@ -136,7 +137,8 @@ async function testCancelledWriteRemovedFromQueue() { totalBytes += chunk.byteLength; } assert.strictEqual(totalBytes, 16384); - await iter.return(); + assert.strictEqual((await iter.next()).done, true); + await end; } async function testOndrainResolvesFalseOnConsumerBreak() { @@ -505,28 +507,48 @@ async function testConsumerThrowRejectsPendingRead() { await readRejects; } -// end() while writes are pending rejects those writes -async function testEndRejectsPendingWrites() { +// end() drains writes that were already pending, then waits for EOF to be read. +async function testEndDrainsPendingWrites() { const kChunk = new Uint8Array(16384); const { writer, readable } = push({ budget: 16384, backpressure: 'unbounded' }); writer.writeSync(kChunk); // fill budget // This write blocks on backpressure const writePromise = writer.write(kChunk); + const endPromise = writer.end(); + await assert.rejects(writer.write(kChunk), { code: 'ERR_INVALID_STATE' }); - await new Promise(setImmediate); + let ended = false; + endPromise.then(common.mustCall(() => { ended = true; })); + const iterator = readable[Symbol.asyncIterator](); - // Ending should reject the pending write - writer.endSync(); + assert.strictEqual((await iterator.next()).done, false); + await writePromise; + assert.strictEqual((await iterator.next()).done, false); + await Promise.resolve(); + assert.strictEqual(ended, false); - await assert.rejects( - () => writePromise, - { code: 'ERR_INVALID_STATE' }, - ); + assert.strictEqual((await iterator.next()).done, true); + assert.strictEqual(await endPromise, kChunk.byteLength * 2); + assert.strictEqual(ended, true); +} - // Clean up: drain the readable - // eslint-disable-next-line no-unused-vars - for await (const _ of readable) { break; } +async function testEndWaitsForEofPull() { + const { writer, readable } = push(); + writer.writeSync('hello'); + const endPromise = writer.end(); + let ended = false; + endPromise.then(common.mustCall(() => { ended = true; })); + const iterator = readable[Symbol.asyncIterator](); + + const data = await iterator.next(); + assert.strictEqual(data.done, false); + await Promise.resolve(); + assert.strictEqual(ended, false); + + assert.strictEqual((await iterator.next()).done, true); + await endPromise; + assert.strictEqual(ended, true); } async function testEndIdempotentWhenClosed() { @@ -679,7 +701,8 @@ Promise.all([ testConsumerReturnResolvesPendingRead(), testEndRejectsAfterConsumerReturn(), testConsumerThrowRejectsPendingRead(), - testEndRejectsPendingWrites(), + testEndDrainsPendingWrites(), + testEndWaitsForEofPull(), testEndIdempotentWhenClosed(), testEndRejectsWhenErrored(), testAsyncDispose(), From d815d3042f65c065dbc569866bd1425f64ac56ad Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 01:44:08 +0000 Subject: [PATCH 16/23] stream: pre-aborted pipeTo now applies dest failure handling Signed-off-by: James M Snell Assisted-by: Opencode --- doc/api/stream_iter.md | 3 +- lib/internal/streams/iter/pull.js | 18 +++++-- .../test-stream-iter-pipeto-signal.js | 47 +++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index b46057f60371..32120aaf4a3c 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -619,7 +619,8 @@ added: * `...transforms` {Function|Object} Zero or more transforms to apply. * `writer` {Object} Destination with `write(chunk)` method. * `options` {Object} - * `signal` {AbortSignal} Abort the pipeline. + * `signal` {AbortSignal} Abort the pipeline. Aborting fails the destination + writer unless `preventFail` is `true`. * `preventClose` {boolean} If `true`, do not call `writer.end()` when the source ends. **Default:** `false`. * `preventFail` {boolean} If `true`, do not call `writer.fail()` on diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index c1e42f03dc38..75a2f1a7f308 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -1054,8 +1054,18 @@ async function pipeTo(source, ...args) { const signal = options?.signal; - // Check for abort - signal?.throwIfAborted(); + function failWriter(error) { + if (!options?.preventFail) { + writer.fail?.(wrapError(error)); + } + } + + try { + signal?.throwIfAborted(); + } catch (error) { + failWriter(error); + throw error; + } const hasWriteSync = typeof writer.writeSync === 'function'; const useSyncIterableFastPath = @@ -1192,9 +1202,7 @@ async function pipeTo(source, ...args) { } } } catch (error) { - if (!options?.preventFail) { - writer.fail?.(wrapError(error)); - } + failWriter(error); throw error; } diff --git a/test/parallel/test-stream-iter-pipeto-signal.js b/test/parallel/test-stream-iter-pipeto-signal.js index ec1324a4e04b..be04c8c635df 100644 --- a/test/parallel/test-stream-iter-pipeto-signal.js +++ b/test/parallel/test-stream-iter-pipeto-signal.js @@ -9,6 +9,51 @@ const assert = require('assert'); const { setTimeout } = require('timers/promises'); const { pipeTo, from } = require('stream/iter'); +async function testPipeToPreAbortedSignalFailsWriter() { + const reason = new Error('already aborted'); + let sourceTouched = false; + const source = { + [Symbol.asyncIterator]() { + sourceTouched = true; + return {}; + }, + }; + const writer = { + write: common.mustNotCall(), + fail: common.mustCall((error) => assert.strictEqual(error, reason)), + }; + + await assert.rejects( + pipeTo(source, writer, { signal: AbortSignal.abort(reason) }), + (error) => error === reason, + ); + assert.strictEqual(sourceTouched, false); +} + +async function testPipeToPreAbortedSignalPreventFail() { + const reason = new Error('already aborted'); + let sourceTouched = false; + const source = { + [Symbol.asyncIterator]() { + sourceTouched = true; + return {}; + }, + }; + const writer = { + write: common.mustNotCall(), + fail: common.mustNotCall(), + }; + + await assert.rejects( + pipeTo(source, writer, { + signal: AbortSignal.abort(reason), + preventFail: true, + }), + (error) => error === reason, + ); + assert.strictEqual(sourceTouched, false); +} + // pipeTo with live signal, no transforms — abort mid-stream async function testPipeToLiveSignalNoTransforms() { const ac = new AbortController(); @@ -116,6 +161,8 @@ async function testPipeToLiveSignalWithTransformsCompletes() { } Promise.all([ + testPipeToPreAbortedSignalFailsWriter(), + testPipeToPreAbortedSignalPreventFail(), testPipeToLiveSignalNoTransforms(), testPipeToLiveSignalNoTransformsPendingNext(), testPipeToLiveSignalWithTransforms(), From b56e7693e00aa7bbf775735ae4cd7702392a7185 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 01:55:01 +0000 Subject: [PATCH 17/23] stream: make pipeTo source normalization independent of Writer Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/pull.js | 53 +----------------------- test/parallel/test-stream-iter-pipeto.js | 39 +++++++++++++---- 2 files changed, 34 insertions(+), 58 deletions(-) diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index 75a2f1a7f308..0be6a3975dbd 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -8,8 +8,6 @@ const { ArrayBufferIsView, - ArrayFromAsync, - ArrayIsArray, ArrayPrototypePush, ArrayPrototypeSlice, FunctionPrototypeCall, @@ -46,9 +44,7 @@ const { fromSync, isSyncIterable, isAsyncIterable, - isPrimitiveChunk, isUint8ArrayBatch, - normalizeAsyncValue, } = require('internal/streams/iter/from'); const { @@ -65,10 +61,7 @@ const { } = require('internal/streams/iter/utils'); const { - kValidatedSource, kValidatedTransform, - toAsyncStreamable, - toStreamable, } = require('internal/streams/iter/types'); // ============================================================================= @@ -131,22 +124,6 @@ function parsePipeToArgs(args, requiredMethod) { }; } -function canUseSyncIterablePipeToFastPath(source, transforms, signal) { - if (signal !== undefined || - transforms.length !== 0 || - isPrimitiveChunk(source) || - ArrayIsArray(source) || - source?.[kValidatedSource] || - !isSyncIterable(source) || - isAsyncIterable(source)) { - return false; - } - - // Preserve from()'s top-level protocol precedence for custom iterables. - return typeof source[toAsyncStreamable] !== 'function' && - typeof source[toStreamable] !== 'function'; -} - // ============================================================================= // Transform Output Flattening // ============================================================================= @@ -1068,9 +1045,7 @@ async function pipeTo(source, ...args) { } const hasWriteSync = typeof writer.writeSync === 'function'; - const useSyncIterableFastPath = - hasWriteSync && canUseSyncIterablePipeToFastPath(source, transforms, signal); - const normalized = useSyncIterableFastPath ? undefined : from(source); + const normalized = from(source); let totalBytes = 0; const hasWritev = typeof writer.writev === 'function'; @@ -1141,31 +1116,7 @@ async function pipeTo(source, ...args) { } try { - if (useSyncIterableFastPath) { - // Avoid from()'s async sync-iterable batching path. This keeps writes - // incremental for synchronous sources while preserving async - // normalization for non-primitive yielded values. - for (const value of source) { - if (isUint8ArrayBatch(value)) { - if (value.length > 0) { - const p = writeBatch(value); - if (p) await p; - } - continue; - } - if (isUint8Array(value)) { - const p = writeBatch([value]); - if (p) await p; - continue; - } - - const batch = await ArrayFromAsync(normalizeAsyncValue(value)); - if (batch.length > 0) { - const p = writeBatch(batch); - if (p) await p; - } - } - } else if (transforms.length === 0) { + if (transforms.length === 0) { // Fast path: no transforms - iterate normalized source directly if (signal) { for await (const batch of yieldAbortable(normalized, signal)) { diff --git a/test/parallel/test-stream-iter-pipeto.js b/test/parallel/test-stream-iter-pipeto.js index f16d7ae89972..4711b291a7af 100644 --- a/test/parallel/test-stream-iter-pipeto.js +++ b/test/parallel/test-stream-iter-pipeto.js @@ -247,7 +247,7 @@ async function testPipeToSyncMinimalWriter() { assert.strictEqual(chunks.length > 0, true); } -async function testPipeToSyncIterableFastPathWritesIncrementally() { +async function testPipeToSyncIterableUsesFromBatching() { let pulled = 0; let firstWritePulled = 0; const chunks = []; @@ -270,7 +270,7 @@ async function testPipeToSyncIterableFastPathWritesIncrementally() { const totalBytes = await pipeTo(source(), writer); assert.strictEqual(totalBytes, 3); - assert.strictEqual(firstWritePulled, 1); + assert.strictEqual(firstWritePulled, 3); assert.deepStrictEqual(chunks, [ new Uint8Array([0x61]), new Uint8Array([0x62]), @@ -278,7 +278,31 @@ async function testPipeToSyncIterableFastPathWritesIncrementally() { ]); } -async function testPipeToSyncIterableFastPathWriteFallback() { +async function testPipeToSourceNormalizationIndependentOfWriter() { + function source() { + return { + *[Symbol.iterator]() { + yield { + async *[Symbol.asyncIterator]() { + yield 'nested'; + }, + }; + }, + }; + } + + for (const hasWriteSync of [false, true]) { + const writer = { write: common.mustNotCall() }; + if (hasWriteSync) writer.writeSync = common.mustNotCall(); + + await assert.rejects( + pipeTo(source(), writer, { preventClose: true, preventFail: true }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + } +} + +async function testPipeToSyncIterableWriteFallback() { const asyncWrites = []; const writer = { writeSync(chunk) { @@ -299,7 +323,7 @@ async function testPipeToSyncIterableFastPathWriteFallback() { assert.deepStrictEqual(asyncWrites, [new Uint8Array([0x62])]); } -async function testPipeToSyncIterableFastPathAsyncValue() { +async function testPipeToSyncIterableAsyncValue() { const chunks = []; const writer = { write: common.mustNotCall(), @@ -337,7 +361,8 @@ Promise.all([ testPipeToSyncPreventClose(), testPipeToMinimalWriter(), testPipeToSyncMinimalWriter(), - testPipeToSyncIterableFastPathWritesIncrementally(), - testPipeToSyncIterableFastPathWriteFallback(), - testPipeToSyncIterableFastPathAsyncValue(), + testPipeToSyncIterableUsesFromBatching(), + testPipeToSyncIterableWriteFallback(), + testPipeToSyncIterableAsyncValue(), + testPipeToSourceNormalizationIndependentOfWriter(), ]).then(common.mustCall()); From 9bf168229a7c2df21c022c44bdcec350b128d14c Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 02:04:08 +0000 Subject: [PATCH 18/23] stream: make consumer signals on longer alter source precedence Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/consumers.js | 4 +-- .../test-stream-iter-consumers-bytes.js | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lib/internal/streams/iter/consumers.js b/lib/internal/streams/iter/consumers.js index 0a317939e8f4..95ab63a054b3 100644 --- a/lib/internal/streams/iter/consumers.js +++ b/lib/internal/streams/iter/consumers.js @@ -143,9 +143,7 @@ async function collectAsync(source, signal, limit) { signal?.throwIfAborted(); // Normalize source via from() - accepts strings, ArrayBuffers, protocols, etc. - const abortableSource = signal && isAsyncIterable(source) ? - yieldAbortable(source, signal) : source; - const normalized = from(abortableSource); + const normalized = from(source); const entries = []; // Fast path: no signal and no limit diff --git a/test/parallel/test-stream-iter-consumers-bytes.js b/test/parallel/test-stream-iter-consumers-bytes.js index 53d0a3858f87..531917eb9734 100644 --- a/test/parallel/test-stream-iter-consumers-bytes.js +++ b/test/parallel/test-stream-iter-consumers-bytes.js @@ -101,6 +101,31 @@ async function testAsyncConsumersAbortPendingNormalization() { } } +async function testAsyncConsumerSignalPreservesProtocolPrecedence() { + let protocolCalls = 0; + let iteratorCalls = 0; + const source = { + __proto__: null, + [toAsyncStreamable]() { + protocolCalls++; + return from('protocol'); + }, + async *[Symbol.asyncIterator]() { + iteratorCalls++; + yield 'iterator'; + }, + }; + + const result = await text(source, { + __proto__: null, + signal: new AbortController().signal, + }); + + assert.strictEqual(result, 'protocol'); + assert.strictEqual(protocolCalls, 1); + assert.strictEqual(iteratorCalls, 0); +} + async function testBytesEmpty() { const data = await bytes(from([])); assert.ok(data instanceof Uint8Array); @@ -255,6 +280,7 @@ Promise.all([ testBytesAsyncAbort(), testAsyncConsumersAbortPendingNext(), testAsyncConsumersAbortPendingNormalization(), + testAsyncConsumerSignalPreservesProtocolPrecedence(), testBytesEmpty(), testArrayBufferSyncBasic(), testArrayBufferAsync(), From f5b7e76b8ab1278c540ec36b292059c5216f73a4 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 02:35:19 +0000 Subject: [PATCH 19/23] stream: apply source normalization once at call time Signed-off-by: James M Snell Assisted-by: Opencode --- doc/api/stream_iter.md | 9 ++-- lib/internal/streams/iter/pull.js | 35 +++++++------ test/parallel/test-stream-iter-pull-async.js | 55 +++++++++++++++++--- test/parallel/test-stream-iter-pull-sync.js | 33 +++++++++++- 4 files changed, 105 insertions(+), 27 deletions(-) diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index 32120aaf4a3c..45509c7f07e4 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -703,8 +703,10 @@ added: * `signal` {AbortSignal} Abort the pipeline. * Returns: {AsyncIterable} whose chunks fulfill with {Uint8Array\[]} -Create a lazy async pipeline. Data is not read from `source` until the -returned iterable is consumed. Transforms are applied in order. +Create a lazy async pipeline. Source conversion and streamable protocol +dispatch occur when `pull()` is called, but data is not read from `source` +until the returned iterable is consumed. A signal that is already aborted is +thrown synchronously after source conversion. Transforms are applied in order. ```mjs import { from, pull, text } from 'node:stream/iter'; @@ -774,7 +776,8 @@ added: * `...transforms` {Function|Object} Zero or more sync transforms. * Returns: {Iterable} whose chunks return {Uint8Array\[]} -Synchronous version of [`pull()`][]. All transforms must be synchronous. +Synchronous version of [`pull()`][]. Source conversion and streamable protocol +dispatch occur when `pullSync()` is called. All transforms must be synchronous. ## Push streams diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index 0be6a3975dbd..6e00ab43f414 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -805,6 +805,7 @@ async function* createAsyncPipeline(source, transforms, signal) { * @returns {Iterable} */ function pullSync(source, ...transforms) { + const normalized = fromSync(source); for (let i = 0; i < transforms.length; i++) { if (!isTransform(transforms[i])) { throw new ERR_INVALID_ARG_TYPE( @@ -815,7 +816,7 @@ function pullSync(source, ...transforms) { return { __proto__: null, *[SymbolIterator]() { - yield* createSyncPipeline(fromSync(source), transforms); + yield* createSyncPipeline(normalized, transforms); }, }; } @@ -832,17 +833,9 @@ function pull(source, ...args) { const signal = options?.signal; if (signal !== undefined) { validateAbortSignal(signal, 'options.signal'); - // Eagerly check abort at call time per spec - if (signal.aborted) { - return { - __proto__: null, - // eslint-disable-next-line require-yield - async *[SymbolAsyncIterator]() { - throw signal.reason; - }, - }; - } } + const normalized = from(source); + signal?.throwIfAborted(); return { __proto__: null, @@ -852,7 +845,7 @@ function pull(source, ...args) { controller.signal : AbortSignal.any([signal, controller.signal]); async function* pipeline() { - yield* createAsyncPipeline(from(source), transforms, iteratorSignal); + yield* createAsyncPipeline(normalized, transforms, iteratorSignal); } const iterator = pipeline(); @@ -887,9 +880,6 @@ function pullWithConsumerCleanup(source, transforms, signal) { return sourceIterator; }, }; - const pipeline = signal === undefined ? - pull(pipelineSource, ...transforms) : - pull(pipelineSource, ...transforms, { __proto__: null, signal }); let sourceClosed = false; let abortHandler; @@ -906,6 +896,21 @@ function pullWithConsumerCleanup(source, transforms, signal) { } } + if (signal?.aborted) { + closeSource('throw', signal.reason); + return { + __proto__: null, + // eslint-disable-next-line require-yield + async *[SymbolAsyncIterator]() { + throw signal.reason; + }, + }; + } + + const pipeline = signal === undefined ? + pull(pipelineSource, ...transforms) : + pull(pipelineSource, ...transforms, { __proto__: null, signal }); + if (signal !== undefined) { abortHandler = () => closeSource('throw', signal.reason); signal.addEventListener('abort', abortHandler, diff --git a/test/parallel/test-stream-iter-pull-async.js b/test/parallel/test-stream-iter-pull-async.js index 86c790c85cb2..6105d9250cad 100644 --- a/test/parallel/test-stream-iter-pull-async.js +++ b/test/parallel/test-stream-iter-pull-async.js @@ -11,6 +11,7 @@ const { share, tap, text, + toAsyncStreamable, } = require('stream/iter'); async function testPullIdentity() { @@ -53,18 +54,54 @@ async function testPullWithAbortSignal() { yield [new Uint8Array([1])]; } - const result = pull(gen(), { signal: AbortSignal.abort() }); - await assert.rejects( - async () => { - // eslint-disable-next-line no-unused-vars - for await (const _ of result) { - assert.fail('Should not reach here'); - } - }, + assert.throws( + () => pull(gen(), { signal: AbortSignal.abort() }), { name: 'AbortError' }, ); } +async function testPullNormalizesSourceAtCallTime() { + let protocolCalls = 0; + let iteratorCalls = 0; + const source = { + [toAsyncStreamable]() { + protocolCalls++; + return { + async *[Symbol.asyncIterator]() { + iteratorCalls++; + yield 'data'; + }, + }; + }, + }; + + const result = pull(source); + assert.strictEqual(protocolCalls, 1); + assert.strictEqual(iteratorCalls, 0); + assert.strictEqual(await text(result), 'data'); + assert.strictEqual(protocolCalls, 1); + assert.strictEqual(iteratorCalls, 1); +} + +function testPullPreAbortOrdering() { + const reason = new Error('already aborted'); + let protocolCalls = 0; + const source = { + [toAsyncStreamable]() { + protocolCalls++; + return from('data'); + }, + }; + const signal = AbortSignal.abort(reason); + + assert.throws(() => pull(source, { signal }), (error) => error === reason); + assert.strictEqual(protocolCalls, 1); + assert.throws( + () => pull(null, { signal }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); +} + async function testPullChainedTransforms() { const enc = new TextEncoder(); const transforms = [ @@ -475,6 +512,8 @@ async function testTransformOptionsNotShared() { testPullStatelessTransform(), testPullStatefulTransform(), testPullWithAbortSignal(), + testPullNormalizesSourceAtCallTime(), + testPullPreAbortOrdering(), testPullChainedTransforms(), testPullSourceError(), testTapCallbackError(), diff --git a/test/parallel/test-stream-iter-pull-sync.js b/test/parallel/test-stream-iter-pull-sync.js index c47a6b3f9233..f14619cc1b42 100644 --- a/test/parallel/test-stream-iter-pull-sync.js +++ b/test/parallel/test-stream-iter-pull-sync.js @@ -3,7 +3,13 @@ const common = require('../common'); const assert = require('assert'); -const { pullSync, fromSync, bytesSync, tapSync } = require('stream/iter'); +const { + pullSync, + fromSync, + bytesSync, + tapSync, + toStreamable, +} = require('stream/iter'); function testPullSyncIdentity() { // No transforms - just pass through @@ -11,6 +17,30 @@ function testPullSyncIdentity() { assert.deepStrictEqual(data, new TextEncoder().encode('hello')); } +function testPullSyncNormalizesSourceAtCallTime() { + let protocolCalls = 0; + let iteratorCalls = 0; + const source = { + [toStreamable]() { + protocolCalls++; + return { + *[Symbol.iterator]() { + iteratorCalls++; + yield 'data'; + }, + }; + }, + }; + + const result = pullSync(source); + assert.strictEqual(protocolCalls, 1); + assert.strictEqual(iteratorCalls, 0); + assert.strictEqual(new TextDecoder().decode(bytesSync(result)), 'data'); + assert.strictEqual(protocolCalls, 1); + assert.strictEqual(iteratorCalls, 1); + assert.throws(() => pullSync(null), { code: 'ERR_INVALID_ARG_TYPE' }); +} + function testPullSyncStatelessTransform() { const upper = (chunks) => { if (chunks === null) return null; @@ -177,6 +207,7 @@ function testPullSyncInvalidTransform() { Promise.all([ testPullSyncIdentity(), + testPullSyncNormalizesSourceAtCallTime(), testPullSyncStatelessTransform(), testPullSyncStatefulTransform(), testPullSyncChainedTransforms(), From 34825891c8d2f66137691e2c328ae2eb56f1983f Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 02:42:36 +0000 Subject: [PATCH 20/23] stream: ensure from() observes returned rejecting promise correctly Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/from.js | 9 ++++++++- test/parallel/test-stream-iter-from-async.js | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/internal/streams/iter/from.js b/lib/internal/streams/iter/from.js index 59ac6a15775b..b33509ba90e6 100644 --- a/lib/internal/streams/iter/from.js +++ b/lib/internal/streams/iter/from.js @@ -15,6 +15,7 @@ const { DataViewPrototypeGetByteLength, DataViewPrototypeGetByteOffset, FunctionPrototypeCall, + PromisePrototypeThen, SymbolAsyncIterator, SymbolIterator, TypedArrayPrototypeGetBuffer, @@ -23,6 +24,8 @@ const { Uint8Array, } = primordials; +const { markPromiseAsHandled } = internalBinding('util'); + const { codes: { ERR_INVALID_ARG_TYPE, @@ -594,7 +597,11 @@ function from(input) { // Check toAsyncStreamable protocol (takes precedence over toStreamable and // iteration protocols) if (typeof input[toAsyncStreamable] === 'function') { - const result = input[toAsyncStreamable](); + let result = input[toAsyncStreamable](); + if (isPromise(result)) { + result = PromisePrototypeThen(result, undefined, undefined); + markPromiseAsHandled(result); + } // Synchronous validated source (e.g. Readable batched iterator) if (result?.[kValidatedSource]) { return result; diff --git a/test/parallel/test-stream-iter-from-async.js b/test/parallel/test-stream-iter-from-async.js index 5ef78088bfbd..cdc6cc23ea2f 100644 --- a/test/parallel/test-stream-iter-from-async.js +++ b/test/parallel/test-stream-iter-from-async.js @@ -235,6 +235,20 @@ async function testFromTopLevelProtocolOverIterator() { assert.strictEqual(result, 'from-protocol'); } +async function testFromHandlesProtocolRejectionUntilIteration() { + const reason = new Error('protocol failed'); + const iterable = from({ + [Symbol.for('Stream.toAsyncStreamable')]: common.mustCall( + () => Promise.reject(reason)), + }); + + await new Promise(setImmediate); + await assert.rejects( + iterable[Symbol.asyncIterator]().next(), + (error) => error === reason, + ); +} + // DataView input should be converted to Uint8Array (zero-copy) async function testFromDataView() { const buf = new ArrayBuffer(5); @@ -279,5 +293,6 @@ Promise.all([ testFromTopLevelToStreamable(), testFromTopLevelAsyncPrecedence(), testFromTopLevelProtocolOverIterator(), + testFromHandlesProtocolRejectionUntilIteration(), testFromDataView(), ]).then(common.mustCall()); From bb979d7e34a6a4bfac97167e36cc2c10423672b7 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 02:46:33 +0000 Subject: [PATCH 21/23] stream: fix nested async flushing with infinite sources Signed-off-by: James M Snell Assisted-by: Opencode --- doc/api/stream_iter.md | 3 ++- lib/internal/streams/iter/from.js | 6 ++++- test/parallel/test-stream-iter-from-async.js | 25 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index 45509c7f07e4..e146c95a4ff4 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -547,7 +547,8 @@ added: Create an async byte stream from the given input. Strings are UTF-8 encoded. `ArrayBuffer` and `ArrayBufferView` values are wrapped as `Uint8Array`. Arrays -and iterables in `input` are recursively flattened and normalized. +and iterables in `input` are recursively flattened and normalized. Flattened +values may be split across implementation-defined bounded batches. Objects implementing `Symbol.for('Stream.toAsyncStreamable')` or `Symbol.for('Stream.toStreamable')` are converted via those protocols. The diff --git a/lib/internal/streams/iter/from.js b/lib/internal/streams/iter/from.js index b33509ba90e6..7fe03511861f 100644 --- a/lib/internal/streams/iter/from.js +++ b/lib/internal/streams/iter/from.js @@ -363,9 +363,13 @@ async function* normalizeAsyncSource(source) { continue; } // Slow path: normalize the value - const batch = []; + let batch = []; for await (const chunk of normalizeAsyncValue(value)) { ArrayPrototypePush(batch, chunk); + if (batch.length === FROM_BATCH_SIZE) { + yield batch; + batch = []; + } } if (batch.length > 0) { yield batch; diff --git a/test/parallel/test-stream-iter-from-async.js b/test/parallel/test-stream-iter-from-async.js index cdc6cc23ea2f..a4726d633ef0 100644 --- a/test/parallel/test-stream-iter-from-async.js +++ b/test/parallel/test-stream-iter-from-async.js @@ -31,6 +31,30 @@ async function testFromAsyncGenerator() { assert.deepStrictEqual(batches[1][0], new Uint8Array([30, 40])); } +async function testFromBoundsNestedAsyncIterable() { + let nestedClosed = false; + async function* nested() { + try { + let value = 0; + while (true) yield new Uint8Array([value++]); + } finally { + nestedClosed = true; + } + } + + async function* source() { + yield nested(); + } + + const iterator = from(source())[Symbol.asyncIterator](); + const first = await iterator.next(); + assert.strictEqual(first.done, false); + assert.strictEqual(first.value.length, 128); + + await iterator.return(); + assert.strictEqual(nestedClosed, true); +} + async function testFromSyncIterableAsAsync() { // Sync iterable passed to from() should work function* gen() { @@ -274,6 +298,7 @@ function testFromUndefinedThrows() { Promise.all([ testFromString(), testFromAsyncGenerator(), + testFromBoundsNestedAsyncIterable(), testFromSyncIterableAsAsync(), testFromSyncIterableAwaitsPromiseValues(), testFromSyncIterableRejectsNestedAsyncIterable(), From e2580182acb4ee74c1c294a58043905c81728dfe Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 02:52:01 +0000 Subject: [PATCH 22/23] stream: ensure that stateful transforms preserve this Signed-off-by: James M Snell Assisted-by: Opencode --- lib/internal/streams/iter/pull.js | 24 ++++++++++++------- test/parallel/test-stream-iter-pull-async.js | 14 +++++++++++ test/parallel/test-stream-iter-pull-sync.js | 15 ++++++++++++ .../test-stream-iter-transform-roundtrip.js | 13 ++++++++++ 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index 6e00ab43f414..d1eb2e2c2409 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -505,8 +505,9 @@ function* withFlushSync(source) { yield null; } -function* applyStatefulSyncTransform(source, transform) { - const output = transform(withFlushSync(source)); +function* applyStatefulSyncTransform(source, transform, receiver) { + const output = FunctionPrototypeCall( + transform, receiver, withFlushSync(source)); for (const item of output) { if (item === null) continue; const batch = []; @@ -537,7 +538,8 @@ function* createSyncPipeline(source, transforms) { current = applyFusedStatelessSyncTransforms(current, statelessRun); statelessRun = []; } - current = applyStatefulSyncTransform(current, transform.transform); + current = applyStatefulSyncTransform( + current, transform.transform, transform); } else { ArrayPrototypePush(statelessRun, transform); } @@ -648,8 +650,10 @@ async function* withFlushAsync(source) { yield null; } -async function* applyStatefulAsyncTransform(source, transform, options) { - const output = transform(withFlushAsync(source), options); +async function* applyStatefulAsyncTransform( + source, transform, receiver, options) { + const output = FunctionPrototypeCall( + transform, receiver, withFlushAsync(source), options); for await (const item of output) { if (item === null) continue; // Fast path: item is already a Uint8Array[] batch (e.g. compression transforms) @@ -681,8 +685,10 @@ async function* applyStatefulAsyncTransform(source, transform, options) { * skips isUint8ArrayBatch validation (transform guarantees valid output). * @yields {Uint8Array[]} */ -async function* applyValidatedStatefulAsyncTransform(source, transform, options) { - const output = transform(source, options); +async function* applyValidatedStatefulAsyncTransform( + source, transform, receiver, options) { + const output = FunctionPrototypeCall( + transform, receiver, source, options); for await (const batch of output) { if (batch.length > 0) { yield batch; @@ -750,10 +756,10 @@ async function* createAsyncPipeline(source, transforms, signal) { const opts = { __proto__: null, signal: transformSignal }; if (transform[kValidatedTransform]) { current = applyValidatedStatefulAsyncTransform( - current, transform.transform, opts); + current, transform.transform, transform, opts); } else { current = applyStatefulAsyncTransform( - current, transform.transform, opts); + current, transform.transform, transform, opts); } } else { ArrayPrototypePush(statelessRun, transform); diff --git a/test/parallel/test-stream-iter-pull-async.js b/test/parallel/test-stream-iter-pull-async.js index 6105d9250cad..c341cf296ddd 100644 --- a/test/parallel/test-stream-iter-pull-async.js +++ b/test/parallel/test-stream-iter-pull-async.js @@ -49,6 +49,19 @@ async function testPullStatefulTransform() { assert.strictEqual(data, 'data-ASYNC-END'); } +async function testPullStatefulTransformReceiver() { + const descriptor = {}; + descriptor.transform = common.mustCall( + async function*(source) { + assert.strictEqual(this, descriptor); + for await (const chunks of source) { + yield chunks; + } + }); + + assert.strictEqual(await text(pull(from('receiver'), descriptor)), 'receiver'); +} + async function testPullWithAbortSignal() { async function* gen() { yield [new Uint8Array([1])]; @@ -511,6 +524,7 @@ async function testTransformOptionsNotShared() { testPullIdentity(), testPullStatelessTransform(), testPullStatefulTransform(), + testPullStatefulTransformReceiver(), testPullWithAbortSignal(), testPullNormalizesSourceAtCallTime(), testPullPreAbortOrdering(), diff --git a/test/parallel/test-stream-iter-pull-sync.js b/test/parallel/test-stream-iter-pull-sync.js index f14619cc1b42..af481dbfa137 100644 --- a/test/parallel/test-stream-iter-pull-sync.js +++ b/test/parallel/test-stream-iter-pull-sync.js @@ -74,6 +74,20 @@ function testPullSyncStatefulTransform() { assert.strictEqual(data, 'data-END'); } +function testPullSyncStatefulTransformReceiver() { + const descriptor = {}; + descriptor.transform = common.mustCall( + function*(source) { + assert.strictEqual(this, descriptor); + yield* source; + }); + + assert.strictEqual( + new TextDecoder().decode(bytesSync(pullSync(fromSync('receiver'), descriptor))), + 'receiver', + ); +} + function testPullSyncChainedTransforms() { const addExcl = (chunks) => { if (chunks === null) return null; @@ -210,6 +224,7 @@ Promise.all([ testPullSyncNormalizesSourceAtCallTime(), testPullSyncStatelessTransform(), testPullSyncStatefulTransform(), + testPullSyncStatefulTransformReceiver(), testPullSyncChainedTransforms(), testPullSyncSourceError(), testPullSyncEmptySource(), diff --git a/test/parallel/test-stream-iter-transform-roundtrip.js b/test/parallel/test-stream-iter-transform-roundtrip.js index df63483d6c85..d9a745593c27 100644 --- a/test/parallel/test-stream-iter-transform-roundtrip.js +++ b/test/parallel/test-stream-iter-transform-roundtrip.js @@ -42,6 +42,18 @@ async function testGzipRoundTrip() { assert.strictEqual(result, input); } +async function testValidatedTransformReceiver() { + const descriptor = compressGzip(); + const transform = descriptor.transform; + descriptor.transform = common.mustCall(function(source, options) { + assert.strictEqual(this, descriptor); + return Reflect.apply(transform, this, [source, options]); + }); + + const result = await bytes(pull(from('receiver'), descriptor)); + assert.ok(result.byteLength > 0); +} + async function testGzipLargeData() { // 100KB of repeated text - exercises multi-chunk path const input = 'gzip large data test. '.repeat(5000); @@ -250,6 +262,7 @@ async function testGzipWithLevel() { (async () => { // Gzip await testGzipRoundTrip(); + await testValidatedTransformReceiver(); await testGzipLargeData(); await testGzipActuallyCompresses(); From 0ce2d7902c2cd0296500a8a57d3baf19de1995b7 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 30 Aug 2026 03:49:45 +0000 Subject: [PATCH 23/23] stream: use webidl validation semantics for args Signed-off-by: James M Snell Assisted-by: Opencode --- doc/api/stream_iter.md | 19 +- lib/internal/fs/promises.js | 50 ++--- lib/internal/quic/quic.js | 62 +++--- lib/internal/streams/iter/broadcast.js | 64 +++---- lib/internal/streams/iter/classic.js | 21 +-- lib/internal/streams/iter/consumers.js | 103 +++++----- lib/internal/streams/iter/duplex.js | 19 +- lib/internal/streams/iter/pull.js | 51 ++--- lib/internal/streams/iter/push.js | 31 +-- lib/internal/streams/iter/share.js | 31 +-- lib/internal/streams/iter/utils.js | 42 ++--- lib/internal/streams/iter/webidl.js | 177 ++++++++++++++++++ .../test-fs-promises-file-handle-writer.js | 22 +++ test/parallel/test-quic-stream-writer-api.mjs | 24 ++- .../test-stream-iter-consumers-text.js | 12 +- test/parallel/test-stream-iter-push-writer.js | 2 +- test/parallel/test-stream-iter-validation.js | 21 +-- test/parallel/test-stream-iter-webidl.js | 159 ++++++++++++++++ .../test-stream-iter-writable-interop.js | 19 +- 19 files changed, 651 insertions(+), 278 deletions(-) create mode 100644 lib/internal/streams/iter/webidl.js create mode 100644 test/parallel/test-stream-iter-webidl.js diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index e146c95a4ff4..b5ed8e06f6e1 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -401,6 +401,12 @@ const { writer, readable } = push({ A writer is any object conforming to the Writer interface. Only `write()` is required; all other methods are optional. +Writer arguments use Web IDL conversion semantics. A non-`Uint8Array` chunk is +converted to a `USVString` and then UTF-8 encoded. `writev()` and +`writevSync()` accept any iterable object whose values can be converted to +chunks. Writer option dictionaries treat `null` as an empty dictionary and +ignore unknown members. + Each async method has a synchronous `*Sync` counterpart designed for a try-fallback pattern: attempt the fast synchronous path first, and fall back to the async version only when the synchronous call indicates it could not @@ -492,7 +498,7 @@ Synchronous write. Does not block; returns `false` if backpressure is active. #### `writer.writev(chunks[, options])` -* `chunks` {Uint8Array\[]|string\[]} +* `chunks` {Iterable} of {Uint8Array|string} values * `options` {Object} * `signal` {AbortSignal} Cancel just this write operation. The signal cancels only the pending `writev()` call; it does not fail the writer itself. @@ -502,7 +508,7 @@ Write multiple chunks as a single batch. #### `writer.writevSync(chunks)` -* `chunks` {Uint8Array\[]|string\[]} +* `chunks` {Iterable} of {Uint8Array|string} values * Returns: {boolean} `true` if the write was accepted, `false` if the buffer is full. @@ -521,6 +527,12 @@ import { from, pull, bytes, Stream } from 'node:stream/iter'; Stream.from('hello'); ``` +Options dictionaries defined by the Iterable Streams API use Web IDL +conversion semantics. `null` is treated as an empty dictionary, unknown +members are ignored, and known members are converted to their declared types +before the operation runs. Conversion failures use Node.js error codes such as +`ERR_INVALID_ARG_TYPE`, `ERR_INVALID_ARG_VALUE`, and `ERR_OUT_OF_RANGE`. + ```cjs // Named exports const { from, pull, bytes, Stream } = require('node:stream/iter'); @@ -860,7 +872,7 @@ added: * `options` {Object} * `budget` {number} Buffer size in bytes for both directions. - **Default:** `16384`. + Must be >= 16384. **Default:** `16384`. * `backpressure` {string} Policy for both directions. **Default:** `'strict'`. * `signal` {AbortSignal} Cancellation signal for both channels. @@ -1379,6 +1391,7 @@ added: **Default:** `65536`. * `backpressure` {string} `'strict'`, `'unbounded'`, `'drop-oldest'`, or `'drop-newest'`. **Default:** `'strict'`. + * `signal` {AbortSignal} * Returns: {Share} Create a pull-model multi-consumer shared stream. Unlike `broadcast()`, the diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index a9d03ebb1d18..c719f2d4b5e9 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -164,8 +164,9 @@ const lazyReadableStream = getLazy(() => let newStreamsPull; let newStreamsPullSync; let newStreamsParsePullArgs; -let newStreamsToUint8Array; +let newStreamsToWriterUint8Array; let newStreamsConvertChunks; +let newStreamsGetWriterSignal; function lazyNewStreams() { if (newStreamsPull === undefined) { const pullModule = require('internal/streams/iter/pull'); @@ -173,8 +174,9 @@ function lazyNewStreams() { newStreamsPullSync = pullModule.pullSync; const utils = require('internal/streams/iter/utils'); newStreamsParsePullArgs = utils.parsePullArgs; - newStreamsToUint8Array = utils.toUint8Array; + newStreamsToWriterUint8Array = utils.toWriterUint8Array; newStreamsConvertChunks = utils.convertChunks; + newStreamsGetWriterSignal = utils.getWriterSignal; } } @@ -885,6 +887,8 @@ if (getOptionValue('--experimental-stream-iter')) { return { __proto__: null, write(chunk, options = kNullPrototo) { + chunk = newStreamsToWriterUint8Array(chunk); + const signal = newStreamsGetWriterSignal(options); if (error) { return PromiseReject(error); } @@ -892,17 +896,9 @@ if (getOptionValue('--experimental-stream-iter')) { return PromiseReject( new ERR_INVALID_STATE.TypeError('The writer is closed')); } - validateObject(options, 'options'); - const { - signal, - } = options; - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - if (signal.aborted) { - return PromiseReject(signal.reason); - } + if (signal?.aborted) { + return PromiseReject(signal.reason); } - chunk = newStreamsToUint8Array(chunk); if (bytesRemaining >= 0 && chunk.byteLength > bytesRemaining) { return PromiseReject( new ERR_OUT_OF_RANGE('write', `<= ${bytesRemaining} bytes`, @@ -915,6 +911,8 @@ if (getOptionValue('--experimental-stream-iter')) { }, writev(chunks, options = kNullPrototo) { + chunks = newStreamsConvertChunks(chunks); + const signal = newStreamsGetWriterSignal(options); if (error) { return PromiseReject(error); } @@ -922,17 +920,9 @@ if (getOptionValue('--experimental-stream-iter')) { return PromiseReject( new ERR_INVALID_STATE.TypeError('The writer is closed')); } - validateObject(options, 'options'); - const { - signal, - } = options; - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - if (signal?.aborted) { - return PromiseReject(signal.reason); - } + if (signal?.aborted) { + return PromiseReject(signal.reason); } - chunks = newStreamsConvertChunks(chunks); let totalSize = 0; for (let i = 0; i < chunks.length; i++) { totalSize += chunks[i].byteLength; @@ -949,8 +939,8 @@ if (getOptionValue('--experimental-stream-iter')) { }, writeSync(chunk) { + chunk = newStreamsToWriterUint8Array(chunk); if (error || closed || asyncPending) return false; - chunk = newStreamsToUint8Array(chunk); const length = chunk.byteLength; if (length > syncWriteThreshold) return false; if (length === 0) return true; @@ -980,8 +970,8 @@ if (getOptionValue('--experimental-stream-iter')) { }, writevSync(chunks) { - if (error || closed || asyncPending) return false; chunks = newStreamsConvertChunks(chunks); + if (error || closed || asyncPending) return false; let totalSize = 0; for (let i = 0; i < chunks.length; i++) { totalSize += chunks[i].byteLength; @@ -1016,6 +1006,7 @@ if (getOptionValue('--experimental-stream-iter')) { }, end(options = kNullPrototo) { + const signal = newStreamsGetWriterSignal(options); if (error) { return PromiseReject(error); } @@ -1025,15 +1016,8 @@ if (getOptionValue('--experimental-stream-iter')) { if (closing) { return pendingEndPromise; } - validateObject(options, 'options'); - const { - signal, - } = options; - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - if (signal.aborted) { - return PromiseReject(signal.reason); - } + if (signal?.aborted) { + return PromiseReject(signal.reason); } closing = true; pendingEndPromise = PromisePrototypeThen( diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 11152f070add..d29fa814560c 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -138,8 +138,9 @@ const { } = require('internal/streams/iter/types'); const { - toUint8Array, convertChunks, + getWriterSignal, + toWriterUint8Array, } = require('internal/streams/iter/utils'); const { @@ -164,7 +165,6 @@ const { } = require('internal/fs/promises'); const { - validateAbortSignal, validateBoolean, validateFunction, validateInteger, @@ -2197,7 +2197,7 @@ class QuicStream { // signals backpressure additional writes are rejected until the buffer has // capacity again. - function writeSync(chunk) { + function writeConvertedSync(chunk) { // If the stream is closed, errored, or write-ended, we cannot accept // more data. Refuse the sync write. // If a drain is already pending, another operation is waiting @@ -2205,7 +2205,6 @@ class QuicStream { if (closed || errored || stream.#inner.state.writeEnded || drainWakeup != null) { return false; } - chunk = toUint8Array(chunk); const len = TypedArrayPrototypeGetByteLength(chunk); if (len === 0) return true; // Refuse the write only when there is no available capacity at @@ -2223,13 +2222,18 @@ class QuicStream { return true; } - async function write(chunk, options = kEmptyObject) { - validateObject(options, 'options'); - const { signal } = options; - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - signal.throwIfAborted(); - } + function writeSync(chunk) { + return writeConvertedSync(toWriterUint8Array(chunk)); + } + + function write(chunk, options = kEmptyObject) { + chunk = toWriterUint8Array(chunk); + const signal = getWriterSignal(options); + return writeAsync(chunk, signal); + } + + async function writeAsync(chunk, signal) { + signal?.throwIfAborted(); if (errored) throw error; if (closed || stream.#inner.state.writeEnded) { throw new ERR_INVALID_STATE('Writer is closed'); @@ -2242,16 +2246,15 @@ class QuicStream { throw new ERR_INVALID_STATE('Stream write buffer is full'); } - if (!writeSync(chunk)) { + if (!writeConvertedSync(chunk)) { throw new ERR_INVALID_STATE('Stream write buffer is full'); } } - function writevSync(chunks) { + function writevConvertedSync(chunks) { if (closed || errored || stream.#inner.state.writeEnded || drainWakeup != null) { return false; } - chunks = convertChunks(chunks); let len = 0; for (const c of chunks) len += TypedArrayPrototypeGetByteLength(c); if (len === 0) return true; @@ -2262,13 +2265,18 @@ class QuicStream { return true; } - async function writev(chunks, options = kEmptyObject) { - validateObject(options, 'options'); - const { signal } = options; - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - signal.throwIfAborted(); - } + function writevSync(chunks) { + return writevConvertedSync(convertChunks(chunks)); + } + + function writev(chunks, options = kEmptyObject) { + chunks = convertChunks(chunks); + const signal = getWriterSignal(options); + return writevAsync(chunks, signal); + } + + async function writevAsync(chunks, signal) { + signal?.throwIfAborted(); if (errored) throw error; if (closed || stream.#inner.state.writeEnded) { @@ -2283,7 +2291,7 @@ class QuicStream { throw new ERR_INVALID_STATE('Stream write buffer is full'); } - if (!writevSync(chunks)) { + if (!writevConvertedSync(chunks)) { throw new ERR_INVALID_STATE('Stream write buffer is full'); } } @@ -2308,11 +2316,13 @@ class QuicStream { return totalBytesWritten; } - async function end(options = kEmptyObject) { - validateObject(options, 'options'); - const { signal } = options; + function end(options = kEmptyObject) { + const signal = getWriterSignal(options); + return endAsync(signal); + } + + async function endAsync(signal) { if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); signal.throwIfAborted(); // TODO(@jasnell): The stream/iter spec allows individual sync end // calls to be canceled via an AbortSignal. We currently do not support diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js index 4131fbf2b8ec..16cb4c06533c 100644 --- a/lib/internal/streams/iter/broadcast.js +++ b/lib/internal/streams/iter/broadcast.js @@ -33,10 +33,7 @@ const { }, } = require('internal/errors'); const { - validateAbortSignal, - validateArray, validateInteger, - validateObject, } = require('internal/validators'); const { @@ -65,10 +62,12 @@ const { onSignalAbort, parsePullArgs, wrapError, - toUint8Array, - validateBackpressure, + toWriterUint8Array, validateBatchEntry, } = require('internal/streams/iter/utils'); +const { + converters, +} = require('internal/streams/iter/webidl'); const { RingBuffer, @@ -135,9 +134,13 @@ class BroadcastImpl { } push(...args) { - const { transforms, options } = parsePullArgs(args); - const signal = options?.signal; - validateAbortSignal(signal, 'options.signal'); + const parsed = parsePullArgs(args); + const { transforms } = parsed; + const options = converters.PullOptions(parsed.options, { + __proto__: null, + context: 'options', + }); + const { signal } = options; // Avoid registering a consumer that the pre-aborted pipeline will never // read or detach. @@ -595,46 +598,30 @@ class BroadcastWriter { } write(chunk, options) { + const converted = toWriterUint8Array(chunk); const signal = getWriterSignal(options); // Fast path: no signal, writer open, buffer has space if (this.#canUseWriteFastPath(signal)) { - const converted = toUint8Array(chunk); const batch = createBatchEntry([converted]); this.#broadcast[kWrite](batch); this.#totalBytes += batch.byteLength; return kResolvedPromise; } - return this.#writevSlow([chunk], signal); + return this.#writeBatchSlow(createBatchEntry([converted]), signal); } writev(chunks, options) { - validateArray(chunks, 'chunks'); + const converted = convertChunks(chunks); const signal = getWriterSignal(options); + const batch = createBatchEntry(converted); // Fast path: no signal, writer open, buffer has space if (this.#canUseWriteFastPath(signal)) { - const converted = convertChunks(chunks); - const batch = createBatchEntry(converted); if (this.#state === 'open' && this.#broadcast[kWrite](batch)) { this.#totalBytes += batch.byteLength; return kResolvedPromise; } return this.#writeBatchSlow(batch, signal); } - return this.#writevSlow(chunks, signal); - } - - async #writevSlow(chunks, signal) { - if (this.#state === 'errored') { - throw this.#error; - } - if (this.#state !== 'open') { - throw new ERR_INVALID_STATE.TypeError('Writer is closed'); - } - - signal?.throwIfAborted(); - - const batch = createBatchEntry(convertChunks(chunks)); - return this.#writeBatchSlow(batch, signal); } @@ -669,9 +656,8 @@ class BroadcastWriter { } writeSync(chunk) { + const converted = toWriterUint8Array(chunk); if (this.#state !== 'open') return false; - const converted = - toUint8Array(chunk); const batch = createBatchEntry([converted]); if (this.#broadcast[kWrite](batch)) { this.#totalBytes += batch.byteLength; @@ -681,9 +667,8 @@ class BroadcastWriter { } writevSync(chunks) { - validateArray(chunks, 'chunks'); - if (this.#state !== 'open') return false; const converted = convertChunks(chunks); + if (this.#state !== 'open') return false; const batch = createBatchEntry(converted); if (this.#broadcast[kWrite](batch)) { this.#totalBytes += batch.byteLength; @@ -863,17 +848,16 @@ function onBroadcastCancel(broadcastImpl, signal) { * @returns {{ writer: Writer, broadcast: Broadcast }} */ function broadcast(options = { __proto__: null }) { - validateObject(options, 'options'); + options = converters.BroadcastOptions(options, { + __proto__: null, + context: 'options', + }); const { budget = kMultiConsumerDefaultBudget, backpressure = 'strict', signal, } = options; validateInteger(budget, 'options.budget', 16384); - validateBackpressure(backpressure); - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - } const opts = { __proto__: null, @@ -916,8 +900,12 @@ const Broadcast = { 'input', ['Broadcastable', 'AsyncIterable', 'Iterable'], input); } + options = converters.BroadcastOptions(options, { + __proto__: null, + context: 'options', + }); const result = broadcast(options); - const signal = options?.signal; + const { signal } = options; const pump = async () => { const w = result.writer; diff --git a/lib/internal/streams/iter/classic.js b/lib/internal/streams/iter/classic.js index 0769348a3981..28f6079e6fe2 100644 --- a/lib/internal/streams/iter/classic.js +++ b/lib/internal/streams/iter/classic.js @@ -40,7 +40,6 @@ const { } = require('internal/errors'); const { - validateArray, validateInteger, validateObject, } = require('internal/validators'); @@ -60,9 +59,10 @@ const { } = require('internal/streams/iter/types'); const { + convertChunks, getWriterSignal, validateBackpressure, - toUint8Array, + toWriterUint8Array, } = require('internal/streams/iter/utils'); const { Buffer } = require('buffer'); @@ -538,7 +538,7 @@ function fromWritable(writable, options = kNullPrototype) { function writeChunks(chunks) { let ok = true; for (let i = 0; i < chunks.length; i++) { - const bytes = toUint8Array(chunks[i]); + const bytes = chunks[i]; totalBytes += TypedArrayPrototypeGetByteLength(bytes); ok = writable.write(bytes); } @@ -554,10 +554,12 @@ function fromWritable(writable, options = kNullPrototype) { }, writeSync(chunk) { + toWriterUint8Array(chunk); return false; }, writevSync(chunks) { + convertChunks(chunks); return false; }, @@ -576,18 +578,12 @@ function fromWritable(writable, options = kNullPrototype) { // otherwise ignored. Classic stream.Writable has no per-write abort signal // support; cancellation should be handled at the pipeline level instead. write(chunk, options) { + const bytes = toWriterUint8Array(chunk); getWriterSignal(options); if (!isWritable()) { return PromiseReject(new ERR_STREAM_WRITE_AFTER_END()); } - let bytes; - try { - bytes = toUint8Array(chunk); - } catch (err) { - return PromiseReject(err); - } - if (backpressure === 'strict' && isFull()) { return PromiseReject(new ERR_INVALID_STATE.RangeError( 'Backpressure violation: buffer is full. ' + @@ -619,7 +615,7 @@ function fromWritable(writable, options = kNullPrototype) { }, writev(chunks, options) { - validateArray(chunks, 'chunks'); + chunks = convertChunks(chunks); getWriterSignal(options); if (!isWritable()) { return PromiseReject(new ERR_STREAM_WRITE_AFTER_END()); @@ -634,8 +630,7 @@ function fromWritable(writable, options = kNullPrototype) { if (backpressure === 'drop-newest' && isFull()) { // Discard entire batch. for (let i = 0; i < chunks.length; i++) { - totalBytes += - TypedArrayPrototypeGetByteLength(toUint8Array(chunks[i])); + totalBytes += TypedArrayPrototypeGetByteLength(chunks[i]); } return PromiseResolve(); } diff --git a/lib/internal/streams/iter/consumers.js b/lib/internal/streams/iter/consumers.js index 95ab63a054b3..75ad4026ad7c 100644 --- a/lib/internal/streams/iter/consumers.js +++ b/lib/internal/streams/iter/consumers.js @@ -36,11 +36,7 @@ const { } = require('internal/errors'); const { TextDecoder } = require('internal/encoding'); const { - validateAbortSignal, validateFunction, - validateInteger, - validateObject, - validateString, } = require('internal/validators'); const { @@ -66,6 +62,9 @@ const { toAsyncStreamable, toStreamable, } = require('internal/streams/iter/types'); +const { + converters, +} = require('internal/streams/iter/webidl'); const { isAnyArrayBuffer, @@ -197,37 +196,6 @@ function toArrayBuffer(data) { byteOffset + byteLength); } -// ============================================================================= -// Shared option validation -// ============================================================================= - -function validateBaseConsumerOptions(options) { - validateObject(options, 'options'); - if (options.limit !== undefined) { - validateInteger(options.limit, 'options.limit', 0); - } - if (options.encoding !== undefined) { - validateString(options.encoding, 'options.encoding'); - try { - new TextDecoder(options.encoding); - } catch { - throw new ERR_INVALID_ARG_VALUE.RangeError( - 'options.encoding', options.encoding); - } - } -} - -function validateConsumerOptions(options) { - validateBaseConsumerOptions(options); - if (options.signal !== undefined) { - validateAbortSignal(options.signal, 'options.signal'); - } -} - -function validateSyncConsumerOptions(options) { - validateBaseConsumerOptions(options); -} - // ============================================================================= // Sync Consumers // ============================================================================= @@ -241,7 +209,10 @@ const kNullPrototype = { __proto__: null }; * @returns {Uint8Array} */ function bytesSync(source, options = kNullPrototype) { - validateSyncConsumerOptions(options); + options = converters.ConsumeSyncOptions(options, { + __proto__: null, + context: 'options', + }); return concatBytes(collectSync(source, options.limit)); } @@ -252,9 +223,18 @@ function bytesSync(source, options = kNullPrototype) { * @returns {string} */ function textSync(source, options = kNullPrototype) { - validateSyncConsumerOptions(options); + options = converters.TextConsumeSyncOptions(options, { + __proto__: null, + context: 'options', + }); + try { + new TextDecoder(options.encoding); + } catch { + throw new ERR_INVALID_ARG_VALUE.RangeError( + 'options.encoding', options.encoding); + } const data = concatBytes(collectSync(source, options.limit)); - const decoder = new TextDecoder(options.encoding ?? 'utf-8', { + const decoder = new TextDecoder(options.encoding, { __proto__: null, fatal: true, }); @@ -268,7 +248,10 @@ function textSync(source, options = kNullPrototype) { * @returns {ArrayBuffer} */ function arrayBufferSync(source, options = kNullPrototype) { - validateSyncConsumerOptions(options); + options = converters.ConsumeSyncOptions(options, { + __proto__: null, + context: 'options', + }); return toArrayBuffer(concatBytes(collectSync(source, options.limit))); } @@ -279,7 +262,10 @@ function arrayBufferSync(source, options = kNullPrototype) { * @returns {Uint8Array[]} */ function arraySync(source, options = kNullPrototype) { - validateSyncConsumerOptions(options); + options = converters.ConsumeSyncOptions(options, { + __proto__: null, + context: 'options', + }); return collectSync(source, options.limit); } @@ -294,7 +280,10 @@ function arraySync(source, options = kNullPrototype) { * @returns {Promise} */ async function bytes(source, options = kNullPrototype) { - validateConsumerOptions(options); + options = converters.ConsumeOptions(options, { + __proto__: null, + context: 'options', + }); const chunks = await collectAsync(source, options.signal, options.limit); return concatBytes(chunks); } @@ -306,10 +295,19 @@ async function bytes(source, options = kNullPrototype) { * @returns {Promise} */ async function text(source, options = kNullPrototype) { - validateConsumerOptions(options); + options = converters.TextConsumeOptions(options, { + __proto__: null, + context: 'options', + }); + try { + new TextDecoder(options.encoding); + } catch { + throw new ERR_INVALID_ARG_VALUE.RangeError( + 'options.encoding', options.encoding); + } const chunks = await collectAsync(source, options.signal, options.limit); const data = concatBytes(chunks); - const decoder = new TextDecoder(options.encoding ?? 'utf-8', { + const decoder = new TextDecoder(options.encoding, { __proto__: null, fatal: true, }); @@ -323,7 +321,10 @@ async function text(source, options = kNullPrototype) { * @returns {Promise} */ async function arrayBuffer(source, options = kNullPrototype) { - validateConsumerOptions(options); + options = converters.ConsumeOptions(options, { + __proto__: null, + context: 'options', + }); const chunks = await collectAsync(source, options.signal, options.limit); return toArrayBuffer(concatBytes(chunks)); } @@ -335,7 +336,10 @@ async function arrayBuffer(source, options = kNullPrototype) { * @returns {Promise} */ async function array(source, options = kNullPrototype) { - validateConsumerOptions(options); + options = converters.ConsumeOptions(options, { + __proto__: null, + context: 'options', + }); return collectAsync(source, options.signal, options.limit); } @@ -419,9 +423,10 @@ function merge(...args) { sources = args; } - if (options?.signal !== undefined) { - validateAbortSignal(options.signal, 'options.signal'); - } + options = converters.MergeOptions(options, { + __proto__: null, + context: 'options', + }); // Normalize each source via from() const normalized = ArrayPrototypeMap(sources, (source) => from(source)); @@ -429,7 +434,7 @@ function merge(...args) { return { __proto__: null, async *[SymbolAsyncIterator]() { - const signal = options?.signal; + const { signal } = options; signal?.throwIfAborted(); diff --git a/lib/internal/streams/iter/duplex.js b/lib/internal/streams/iter/duplex.js index a60c510c4199..674ef81a53c9 100644 --- a/lib/internal/streams/iter/duplex.js +++ b/lib/internal/streams/iter/duplex.js @@ -16,9 +16,8 @@ const { push, } = require('internal/streams/iter/push'); const { - validateAbortSignal, - validateObject, -} = require('internal/validators'); + converters, +} = require('internal/streams/iter/webidl'); /** * Create a pair of connected duplex channels for bidirectional communication. @@ -27,17 +26,11 @@ const { * @returns {[DuplexChannel, DuplexChannel]} */ function duplex(options = { __proto__: null }) { - validateObject(options, 'options'); + options = converters.DuplexOptions(options, { + __proto__: null, + context: 'options', + }); const { budget, backpressure, signal, a, b } = options; - if (a !== undefined) { - validateObject(a, 'options.a'); - } - if (b !== undefined) { - validateObject(b, 'options.b'); - } - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - } // Channel A writes to B's readable (A->B direction). // Signal is NOT passed to push() -- we handle abort via close() below. diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index d1eb2e2c2409..6b44f5c5e431 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -27,7 +27,6 @@ const { }, } = require('internal/errors'); const { lazyDOMException } = require('internal/util'); -const { validateAbortSignal } = require('internal/validators'); const { isAnyArrayBuffer, isPromise, @@ -49,7 +48,6 @@ const { const { createBatchEntry, - isPullOptions, isTransform, isTransformObject, parsePullArgs, @@ -59,6 +57,9 @@ const { wrapError, yieldAbortable, } = require('internal/streams/iter/utils'); +const { + converters, +} = require('internal/streams/iter/webidl'); const { kValidatedTransform, @@ -80,7 +81,7 @@ function hasMethod(value, name) { * Parse pipeTo/pipeToSync arguments: [...transforms, writer, options?] * @param {Array} args * @param {string} requiredMethod - 'write' for pipeTo, 'writeSync' for pipeToSync - * @returns {{ transforms: Array, writer: object, options: object }} + * @returns {{ transforms: Array, writer: object, options: unknown }} */ function parsePipeToArgs(args, requiredMethod) { if (args.length === 0) { @@ -92,7 +93,7 @@ function parsePipeToArgs(args, requiredMethod) { // Check if last arg is options const last = args[args.length - 1]; - if (isPullOptions(last) && !hasMethod(last, requiredMethod)) { + if (!isTransform(last) && !hasMethod(last, requiredMethod)) { options = last; writerIndex = args.length - 2; } @@ -835,11 +836,13 @@ function pullSync(source, ...transforms) { * @returns {AsyncIterable} */ function pull(source, ...args) { - const { transforms, options } = parsePullArgs(args); - const signal = options?.signal; - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - } + const parsed = parsePullArgs(args); + const { transforms } = parsed; + const options = converters.PullOptions(parsed.options, { + __proto__: null, + context: 'options', + }); + const { signal } = options; const normalized = from(source); signal?.throwIfAborted(); @@ -969,11 +972,16 @@ function pullWithConsumerCleanup(source, transforms, signal) { * @returns {number} Total bytes written */ function pipeToSync(source, ...args) { - const { transforms, writer, options } = parsePipeToArgs(args, 'writeSync'); + const parsed = parsePipeToArgs(args, 'writeSync'); + const { transforms, writer } = parsed; + const options = converters.PipeToSyncOptions(parsed.options, { + __proto__: null, + context: 'options', + }); const hasWritevSync = typeof writer.writevSync === 'function'; const endSync = writer.endSync; - if (!options?.preventClose && typeof endSync !== 'function') { + if (!options.preventClose && typeof endSync !== 'function') { throw new ERR_INVALID_ARG_TYPE( 'writer.endSync', 'Function', endSync); } @@ -1012,14 +1020,14 @@ function pipeToSync(source, ...args) { } } - if (!options?.preventClose) { + if (!options.preventClose) { if (FunctionPrototypeCall(endSync, writer) < 0) { throw new ERR_INVALID_STATE( 'Writer could not be closed synchronously'); } } } catch (error) { - if (!options?.preventFail) { + if (!options.preventFail) { writer.fail?.(wrapError(error)); } throw error; @@ -1035,15 +1043,16 @@ function pipeToSync(source, ...args) { * @returns {Promise} Total bytes written */ async function pipeTo(source, ...args) { - const { transforms, writer, options } = parsePipeToArgs(args, 'write'); - if (options?.signal !== undefined) { - validateAbortSignal(options.signal, 'options.signal'); - } - - const signal = options?.signal; + const parsed = parsePipeToArgs(args, 'write'); + const { transforms, writer } = parsed; + const options = converters.PipeToOptions(parsed.options, { + __proto__: null, + context: 'options', + }); + const { signal } = options; function failWriter(error) { - if (!options?.preventFail) { + if (!options.preventFail) { writer.fail?.(wrapError(error)); } } @@ -1158,7 +1167,7 @@ async function pipeTo(source, ...args) { } } - if (!options?.preventClose) { + if (!options.preventClose) { if (!hasEndSync || writer.endSync() < 0) { await writer.end?.(signal ? { __proto__: null, signal } : undefined); } diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index 8d680cd2ad01..0536d8212c2a 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -25,8 +25,6 @@ const { } = require('internal/errors'); const { lazyDOMException } = require('internal/util'); const { - validateAbortSignal, - validateArray, validateInteger, } = require('internal/validators'); @@ -39,13 +37,15 @@ const { kResolvedPromise, createBatchEntry, onSignalAbort, - toUint8Array, + toWriterUint8Array, convertChunks, getWriterSignal, parsePullArgs, - validateBackpressure, validateBatchEntry, } = require('internal/streams/iter/utils'); +const { + converters, +} = require('internal/streams/iter/webidl'); const { pullWithConsumerCleanup, @@ -124,16 +124,16 @@ class PushQueue { #bufferedBytes = 0; constructor(options = { __proto__: null }) { + options = converters.PushStreamOptions(options, { + __proto__: null, + context: 'options', + }); const { budget = kPushDefaultBudget, backpressure = 'strict', signal, } = options; validateInteger(budget, 'options.budget', 16384); - validateBackpressure(backpressure); - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - } this.#budget = budget; this.#backpressure = backpressure; this.#signal = signal; @@ -166,6 +166,10 @@ class PushQueue { return true; } + get signal() { + return this.#signal; + } + /** * Check if a sync write would be accepted. * @returns {boolean} @@ -651,20 +655,18 @@ class PushWriter { } write(chunk, options) { + const bytes = toWriterUint8Array(chunk); const signal = getWriterSignal(options); if (!signal && this.#queue.canWriteSync()) { - const bytes = toUint8Array(chunk); this.#queue.writeSync([bytes]); return kResolvedPromise; } - const bytes = toUint8Array(chunk); return this.#queue.writeAsync([bytes], signal); } writev(chunks, options) { - validateArray(chunks, 'chunks'); - const signal = getWriterSignal(options); const bytes = convertChunks(chunks); + const signal = getWriterSignal(options); if (!signal && this.#queue.writeSync(bytes)) { return kResolvedPromise; } @@ -672,12 +674,11 @@ class PushWriter { } writeSync(chunk) { - const bytes = toUint8Array(chunk); + const bytes = toWriterUint8Array(chunk); return this.#queue.writeSync([bytes]); } writevSync(chunks) { - validateArray(chunks, 'chunks'); const bytes = convertChunks(chunks); return this.#queue.writeSync(bytes); } @@ -780,7 +781,7 @@ function push(...args) { let readable; if (transforms.length > 0) { readable = pullWithConsumerCleanup( - rawReadable, transforms, options.signal); + rawReadable, transforms, queue.signal); } else { readable = rawReadable; } diff --git a/lib/internal/streams/iter/share.js b/lib/internal/streams/iter/share.js index 00d9d387ce82..70179dffd3c1 100644 --- a/lib/internal/streams/iter/share.js +++ b/lib/internal/streams/iter/share.js @@ -43,9 +43,11 @@ const { onSignalAbort, wrapError, parsePullArgs, - validateBackpressure, validateBatchEntry, } = require('internal/streams/iter/utils'); +const { + converters, +} = require('internal/streams/iter/webidl'); const { RingBuffer, @@ -59,9 +61,7 @@ const { }, } = require('internal/errors'); const { - validateAbortSignal, validateInteger, - validateObject, } = require('internal/validators'); // ============================================================================= @@ -104,9 +104,13 @@ class ShareImpl { } pull(...args) { - const { transforms, options } = parsePullArgs(args); - const signal = options?.signal; - validateAbortSignal(signal, 'options.signal'); + const parsed = parsePullArgs(args); + const { transforms } = parsed; + const options = converters.PullOptions(parsed.options, { + __proto__: null, + context: 'options', + }); + const { signal } = options; // Avoid registering a consumer that the pre-aborted pipeline will never // read or detach. @@ -746,17 +750,16 @@ function onShareCancel(shareImpl, signal) { function share(source, options = { __proto__: null }) { // Normalize source via from() - accepts strings, ArrayBuffers, protocols, etc. const normalized = from(source); - validateObject(options, 'options'); + options = converters.ShareOptions(options, { + __proto__: null, + context: 'options', + }); const { budget = kMultiConsumerDefaultBudget, backpressure = 'strict', signal, } = options; validateInteger(budget, 'options.budget', 16384); - validateBackpressure(backpressure); - if (signal !== undefined) { - validateAbortSignal(signal, 'options.signal'); - } const opts = { __proto__: null, @@ -777,13 +780,15 @@ function share(source, options = { __proto__: null }) { function shareSync(source, options = { __proto__: null }) { // Normalize source via fromSync() - accepts strings, ArrayBuffers, protocols, etc. const normalized = fromSync(source); - validateObject(options, 'options'); + options = converters.ShareSyncOptions(options, { + __proto__: null, + context: 'options', + }); const { budget = kMultiConsumerDefaultBudget, backpressure = 'strict', } = options; validateInteger(budget, 'options.budget', 16384); - validateBackpressure(backpressure); const opts = { __proto__: null, diff --git a/lib/internal/streams/iter/utils.js b/lib/internal/streams/iter/utils.js index 66e831a58523..08337a5536f2 100644 --- a/lib/internal/streams/iter/utils.js +++ b/lib/internal/streams/iter/utils.js @@ -35,9 +35,11 @@ const { isError } = require('internal/util'); const { isSharedArrayBuffer, isUint8Array } = require('internal/util/types'); const { - validateAbortSignal, validateOneOf, } = require('internal/validators'); +const { + converters, +} = require('internal/streams/iter/webidl'); // Cached resolved promise to avoid allocating a new one on every sync fast-path. const kResolvedPromise = PromiseResolve(); @@ -304,6 +306,10 @@ function concatBytes(chunks) { * @returns {Uint8Array[]} */ function convertChunks(chunks) { + chunks = converters.WriterChunkSequence(chunks, { + __proto__: null, + context: 'chunks', + }); const len = chunks.length; const result = new Array(len); for (let i = 0; i < len; i++) { @@ -318,9 +324,17 @@ function convertChunks(chunks) { * @returns {AbortSignal|undefined} */ function getWriterSignal(options) { - const signal = options?.signal; - validateAbortSignal(signal, 'options.signal'); - return signal; + return converters.WriteOptions(options, { + __proto__: null, + context: 'options', + }).signal; +} + +function toWriterUint8Array(chunk) { + return toUint8Array(converters.WriterChunk(chunk, { + __proto__: null, + context: 'chunk', + })); } /** @@ -348,20 +362,6 @@ function hasProtocol(value, symbol) { ); } -/** - * Check if a value is PullOptions (object without transform or write property). - * @param {unknown} value - * @returns {boolean} - */ -function isPullOptions(value) { - return ( - value !== null && - typeof value === 'object' && - !('transform' in value) && - !('write' in value) - ); -} - /** * Check if a value is a stateful transform object (has a transform method). * @param {unknown} value @@ -384,7 +384,7 @@ function isTransform(value) { * Parse variadic arguments for pull/pullSync. * Returns { transforms, options } * @param {Array} args - * @returns {{ transforms: Array, options: object|undefined }} + * @returns {{ transforms: Array, options: unknown }} */ function parsePullArgs(args) { if (args.length === 0) { @@ -394,7 +394,7 @@ function parsePullArgs(args) { let transforms; let options; const last = args[args.length - 1]; - if (isPullOptions(last)) { + if (!isTransform(last)) { transforms = ArrayPrototypeSlice(args, 0, -1); options = last; } else { @@ -436,12 +436,12 @@ module.exports = { getWriterSignal, getMinCursor, hasProtocol, - isPullOptions, isTransform, isTransformObject, onSignalAbort, parsePullArgs, toUint8Array, + toWriterUint8Array, validateBackpressure, validateBatchEntry, validateByteView, diff --git a/lib/internal/streams/iter/webidl.js b/lib/internal/streams/iter/webidl.js new file mode 100644 index 000000000000..b971fa95071b --- /dev/null +++ b/lib/internal/streams/iter/webidl.js @@ -0,0 +1,177 @@ +'use strict'; + +const { + converters: baseConverters, + convertToInt, + createDictionaryConverter, + createEnumConverter, + createInterfaceConverter, + createSequenceConverter, +} = require('internal/webidl'); +const { AbortSignal } = require('internal/abort_controller'); +const { isUint8Array } = require('internal/util/types'); + +const converters = { __proto__: null }; + +function unsignedLongLong(value, options) { + return convertToInt(value, 64, 'unsigned', options); +} + +function enforceRangeUnsignedLongLong(value, options = { __proto__: null }) { + return convertToInt(value, 64, 'unsigned', { + __proto__: null, + prefix: options.prefix, + context: options.context, + code: options.code, + enforceRange: true, + }); +} + +function allowStreamBufferOptions(options) { + return { + __proto__: null, + prefix: options.prefix, + context: options.context, + code: options.code, + allowShared: true, + allowResizable: true, + }; +} + +converters.AbortSignal = createInterfaceConverter( + 'AbortSignal', AbortSignal.prototype); +converters.BackpressurePolicy = createEnumConverter('BackpressurePolicy', [ + 'strict', + 'unbounded', + 'drop-oldest', + 'drop-newest', +]); +converters.unsignedLongLong = unsignedLongLong; +converters.enforceRangeUnsignedLongLong = enforceRangeUnsignedLongLong; +converters.WriterChunk = (value, options = { __proto__: null }) => { + if (isUint8Array(value)) { + return baseConverters.Uint8Array( + value, allowStreamBufferOptions(options)); + } + return baseConverters.USVString(value, options); +}; +converters.WriterChunkSequence = createSequenceConverter( + converters.WriterChunk); + +const signalMember = { + __proto__: null, + key: 'signal', + converter: converters.AbortSignal, +}; +const budgetMember = { + __proto__: null, + key: 'budget', + converter: converters.unsignedLongLong, +}; +const backpressureMember = { + __proto__: null, + key: 'backpressure', + converter: converters.BackpressurePolicy, + defaultValue: () => 'strict', +}; +const limitMember = { + __proto__: null, + key: 'limit', + converter: converters.enforceRangeUnsignedLongLong, +}; + +converters.WriteOptions = createDictionaryConverter('WriteOptions', [ + signalMember, +]); +converters.PushStreamOptions = createDictionaryConverter( + 'PushStreamOptions', [budgetMember, backpressureMember, signalMember]); +converters.PullOptions = createDictionaryConverter('PullOptions', [ + signalMember, +]); +converters.PipeToOptions = createDictionaryConverter('PipeToOptions', [ + { + __proto__: null, + key: 'preventClose', + converter: baseConverters.boolean, + defaultValue: () => false, + }, + { + __proto__: null, + key: 'preventFail', + converter: baseConverters.boolean, + defaultValue: () => false, + }, + signalMember, +]); +converters.PipeToSyncOptions = createDictionaryConverter( + 'PipeToSyncOptions', [ + { + __proto__: null, + key: 'preventClose', + converter: baseConverters.boolean, + defaultValue: () => false, + }, + { + __proto__: null, + key: 'preventFail', + converter: baseConverters.boolean, + defaultValue: () => false, + }, + ]); +converters.ConsumeOptions = createDictionaryConverter('ConsumeOptions', [ + limitMember, + signalMember, +]); +converters.ConsumeSyncOptions = createDictionaryConverter( + 'ConsumeSyncOptions', [limitMember]); +const encodingMember = { + __proto__: null, + key: 'encoding', + converter: baseConverters.DOMString, + defaultValue: () => 'utf-8', +}; +converters.TextConsumeOptions = createDictionaryConverter( + 'TextConsumeOptions', [ + [limitMember, signalMember], + [encodingMember], + ]); +converters.TextConsumeSyncOptions = createDictionaryConverter( + 'TextConsumeSyncOptions', [ + [limitMember], + [encodingMember], + ]); +converters.MergeOptions = createDictionaryConverter('MergeOptions', [ + signalMember, +]); +converters.BroadcastOptions = createDictionaryConverter( + 'BroadcastOptions', [budgetMember, backpressureMember, signalMember]); +converters.ShareOptions = createDictionaryConverter( + 'ShareOptions', [budgetMember, backpressureMember, signalMember]); +converters.ShareSyncOptions = createDictionaryConverter( + 'ShareSyncOptions', [budgetMember, backpressureMember]); +converters.DuplexDirectionOptions = createDictionaryConverter( + 'DuplexDirectionOptions', [ + budgetMember, + { + __proto__: null, + key: 'backpressure', + converter: converters.BackpressurePolicy, + }, + ]); +converters.DuplexOptions = createDictionaryConverter('DuplexOptions', [ + { + __proto__: null, + key: 'a', + converter: converters.DuplexDirectionOptions, + }, + { + __proto__: null, + key: 'b', + converter: converters.DuplexDirectionOptions, + }, + budgetMember, + backpressureMember, + signalMember, +]); + +module.exports = { converters }; diff --git a/test/parallel/test-fs-promises-file-handle-writer.js b/test/parallel/test-fs-promises-file-handle-writer.js index ff90716400ef..d43bababad1e 100644 --- a/test/parallel/test-fs-promises-file-handle-writer.js +++ b/test/parallel/test-fs-promises-file-handle-writer.js @@ -1060,6 +1060,27 @@ async function testWriterArgumentValidation() { } } +async function testWriterWebIDLConversion() { + const filePath = path.join(tmpDir, 'writer-webidl.txt'); + const fh = await open(filePath, 'w'); + const w = fh.writer(); + + await w.write(42, null); + await w.writev(new Set([true, { toString: () => 'object' }])); + assert.throws( + () => w.write(Symbol('invalid')), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + assert.throws( + () => w.write('invalid options', 1), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + assert.strictEqual(await w.end(null), 12); + await fh.close(); + + assert.strictEqual(fs.readFileSync(filePath, 'utf8'), '42trueobject'); +} + // ============================================================================= // Run all tests // ============================================================================= @@ -1114,4 +1135,5 @@ Promise.all([ testWriterLimitWritevSync(), testWriterLimitAndStart(), testWriterArgumentValidation(), + testWriterWebIDLConversion(), ]).then(common.mustCall()); diff --git a/test/parallel/test-quic-stream-writer-api.mjs b/test/parallel/test-quic-stream-writer-api.mjs index 009cb4ff8a4a..4f19502d8579 100644 --- a/test/parallel/test-quic-stream-writer-api.mjs +++ b/test/parallel/test-quic-stream-writer-api.mjs @@ -16,7 +16,7 @@ const { bytes } = await import('stream/iter'); const encoder = new TextEncoder(); -const totalStreams = 5; +const totalStreams = 6; const serverResults = []; const allDone = Promise.withResolvers(); @@ -88,6 +88,25 @@ await clientSession.opened; await stream.closed; } +// Web IDL Writer argument conversion +{ + const stream = await clientSession.createBidirectionalStream(); + const w = stream.writer; + await w.write(42, null); + await w.writev(new Set([true, { toString: () => 'object' }])); + assert.throws( + () => w.write(Symbol('invalid')), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + assert.throws( + () => w.write('invalid options', 1), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + assert.strictEqual(w.endSync(), 12); + for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars + await stream.closed; +} + { const stream = await clientSession.createBidirectionalStream(); const w = stream.writer; @@ -139,4 +158,5 @@ assert.strictEqual(decoder.decode(serverResults[0]), 'async write'); assert.strictEqual(decoder.decode(serverResults[1]), 'hello writev'); assert.strictEqual(decoder.decode(serverResults[2]), 'async writev'); assert.strictEqual(decoder.decode(serverResults[3]), 'end async'); -assert.strictEqual(decoder.decode(serverResults[4]), 'capacity'); +assert.strictEqual(decoder.decode(serverResults[4]), '42trueobject'); +assert.strictEqual(decoder.decode(serverResults[5]), 'capacity'); diff --git a/test/parallel/test-stream-iter-consumers-text.js b/test/parallel/test-stream-iter-consumers-text.js index a8fb7a74367f..68be098ff8f6 100644 --- a/test/parallel/test-stream-iter-consumers-text.js +++ b/test/parallel/test-stream-iter-consumers-text.js @@ -143,17 +143,17 @@ function testTextSyncUnsupportedEncodingThrowsRangeError() { ); } -async function testTextNonStringEncodingThrowsTypeError() { +async function testTextConvertedEncodingThrowsRangeError() { await assert.rejects( () => text(from('hello'), { encoding: 1 }), - { code: 'ERR_INVALID_ARG_TYPE' }, + { code: 'ERR_INVALID_ARG_VALUE' }, ); } -function testTextSyncNonStringEncodingThrowsTypeError() { +function testTextSyncConvertedEncodingThrowsRangeError() { assert.throws( () => textSync(fromSync('hello'), { encoding: 1 }), - { code: 'ERR_INVALID_ARG_TYPE' }, + { code: 'ERR_INVALID_ARG_VALUE' }, ); } @@ -173,6 +173,6 @@ Promise.all([ testTextSyncBOMStripped(), testTextUnsupportedEncodingThrowsRangeError(), testTextSyncUnsupportedEncodingThrowsRangeError(), - testTextNonStringEncodingThrowsTypeError(), - testTextSyncNonStringEncodingThrowsTypeError(), + testTextConvertedEncodingThrowsRangeError(), + testTextSyncConvertedEncodingThrowsRangeError(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-push-writer.js b/test/parallel/test-stream-iter-push-writer.js index 98f084ab01a6..854bd69f0ac6 100644 --- a/test/parallel/test-stream-iter-push-writer.js +++ b/test/parallel/test-stream-iter-push-writer.js @@ -216,7 +216,7 @@ async function testWritevSyncInvalidChunkDoesNotQueue() { const { writer, readable } = push({ budget: 16384 }); assert.throws( - () => writer.writevSync([1]), + () => writer.writevSync([Symbol('invalid')]), { code: 'ERR_INVALID_ARG_TYPE' }, ); diff --git a/test/parallel/test-stream-iter-validation.js b/test/parallel/test-stream-iter-validation.js index b93e6575490f..871c891a6539 100644 --- a/test/parallel/test-stream-iter-validation.js +++ b/test/parallel/test-stream-iter-validation.js @@ -22,7 +22,7 @@ const { // ============================================================================= // Budget must be integer >= 16384 -assert.throws(() => push({ budget: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' }); +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 assert.throws(() => push({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' }); @@ -68,12 +68,11 @@ assert.throws(() => push('bad', {}), { code: 'ERR_INVALID_ARG_TYPE' }); writer.endSync(); } -// Writer.write rejects non-string/non-Uint8Array +// Writer chunks use the Web IDL (Uint8Array or USVString) conversion. { const { writer } = push(); - assert.throws(() => writer.writeSync(42), { code: 'ERR_INVALID_ARG_TYPE' }); - assert.throws(() => writer.writeSync({}), { code: 'ERR_INVALID_ARG_TYPE' }); - assert.throws(() => writer.writeSync(true), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => writer.writeSync(Symbol()), + { code: 'ERR_INVALID_ARG_TYPE' }); writer.endSync(); } @@ -87,7 +86,7 @@ assert.throws(() => duplex({ a: 42 }), { code: 'ERR_INVALID_ARG_TYPE' }); assert.throws(() => duplex({ b: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' }); // Budget validation (cascades through to push()) -assert.throws(() => duplex({ budget: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' }); +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: Number.MAX_SAFE_INTEGER + 1 }), { code: 'ERR_OUT_OF_RANGE' }); @@ -129,7 +128,7 @@ assert.throws(() => pullSync(fromSync('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' // broadcast() validation // ============================================================================= -assert.throws(() => broadcast({ budget: 'bad' }), { 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: Number.MAX_SAFE_INTEGER + 1 }), { code: 'ERR_OUT_OF_RANGE' }); @@ -214,7 +213,7 @@ assert.throws(() => Broadcast.from(42), { code: 'ERR_INVALID_ARG_TYPE' }); // ============================================================================= assert.throws(() => share(42), { code: 'ERR_INVALID_ARG_TYPE' }); -assert.throws(() => share(from('a'), { budget: 'bad' }), { 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: Number.MAX_SAFE_INTEGER + 1 }), { code: 'ERR_OUT_OF_RANGE' }); @@ -239,7 +238,7 @@ 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_INVALID_ARG_TYPE' }); + { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => shareSync(fromSync('a'), { budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => shareSync(fromSync('a'), { budget: Number.MAX_SAFE_INTEGER + 1 }), @@ -274,7 +273,7 @@ assert.throws(() => bytesSync(fromSync('a'), { limit: 'bad' }), assert.throws(() => bytesSync(fromSync('a'), { limit: -1 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => textSync(fromSync('a'), { encoding: 42 }), - { code: 'ERR_INVALID_ARG_TYPE' }); + { code: 'ERR_INVALID_ARG_VALUE' }); assert.throws(() => textSync(fromSync('a'), { encoding: 'bogus' }), { code: 'ERR_INVALID_ARG_VALUE' }); assert.throws(() => arrayBufferSync(fromSync('a'), { limit: 'bad' }), @@ -318,7 +317,7 @@ async function testAsyncValidation() { await assert.rejects( () => bytes(from('a'), { limit: -1 }), { code: 'ERR_OUT_OF_RANGE' }); await assert.rejects( - () => text(from('a'), { encoding: 42 }), { code: 'ERR_INVALID_ARG_TYPE' }); + () => text(from('a'), { encoding: 42 }), { code: 'ERR_INVALID_ARG_VALUE' }); await assert.rejects( () => text(from('a'), { encoding: 'not-a-real-encoding' }), { code: 'ERR_INVALID_ARG_VALUE' }); diff --git a/test/parallel/test-stream-iter-webidl.js b/test/parallel/test-stream-iter-webidl.js new file mode 100644 index 000000000000..904bf77a647f --- /dev/null +++ b/test/parallel/test-stream-iter-webidl.js @@ -0,0 +1,159 @@ +// Flags: --experimental-stream-iter +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { Writable } = require('stream'); +const { + Broadcast, + broadcast, + bytesSync, + from, + fromWritable, + fromSync, + pipeTo, + pull, + push, + share, + shareSync, + text, + textSync, +} = require('stream/iter'); + +function testDictionaryAndIntegerConversion() { + const pushed = push(null); + assert.strictEqual(pushed.writer.endSync(), 0); + const transformedWithNull = push((chunks) => chunks, null); + transformedWithNull.writer.endSync(); + + const broadcasted = broadcast(null); + assert.strictEqual(broadcasted.writer.endSync(), 0); + + share(from(''), null).cancel(); + shareSync(fromSync(''), null).cancel(); + + assert.deepStrictEqual(bytesSync(fromSync('data'), null), + new TextEncoder().encode('data')); + assert.deepStrictEqual( + bytesSync(fromSync('data'), { limit: '4.9' }), + new TextEncoder().encode('data'), + ); + assert.throws( + () => bytesSync(fromSync('data'), { limit: -1 }), + { name: 'TypeError', code: 'ERR_OUT_OF_RANGE' }, + ); + + const converted = push({ + budget: '16384.9', + backpressure: { toString: () => 'strict' }, + }); + assert.strictEqual(converted.writer.canWrite, true); + converted.writer.endSync(); + + let signalReads = 0; + const transformed = push((chunks) => chunks, { + get signal() { + signalReads++; + return undefined; + }, + }); + assert.strictEqual(signalReads, 1); + transformed.writer.endSync(); +} + +async function testUnknownDictionaryMembers() { + const source = pull(from('pull'), { + transform: 1, + write: 1, + unknown: true, + }); + assert.strictEqual(await text(source), 'pull'); + + let ended = false; + const writer = { + write() {}, + end() { ended = true; }, + }; + await pipeTo(from('pipe'), writer, { + transform: 1, + write: 1, + }); + assert.strictEqual(ended, true); + + const options = { + get encoding() { + throw new Error('unknown member was read'); + }, + }; + assert.deepStrictEqual(bytesSync(fromSync('bytes'), options), + new TextEncoder().encode('bytes')); + assert.strictEqual(textSync(fromSync('text'), { + encoding: { toString: () => 'utf-8' }, + }), 'text'); +} + +async function testWriterConversion() { + const { writer, readable } = push(); + + await writer.write(42, null); + assert.strictEqual(writer.writevSync(new Set([ + null, + true, + { toString: () => 'object' }, + ])), true); + + assert.throws( + () => writer.write(Symbol('invalid')), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + assert.throws( + () => writer.writev('not a sequence object'), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + assert.throws( + () => writer.write('invalid options', 1), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + + writer.endSync(); + assert.strictEqual(await text(readable), '42nulltrueobject'); +} + +async function testOtherWriterConversions() { + const output = []; + const writable = new Writable({ + write(chunk, encoding, callback) { + output.push(chunk.toString()); + callback(); + }, + }); + const classicWriter = fromWritable(writable); + await classicWriter.write(42, null); + await classicWriter.writev(new Set([false, '!'])); + await classicWriter.end(null); + assert.strictEqual(output.join(''), '42false!'); + + const result = broadcast(); + const source = result.broadcast.push(); + await result.writer.write(42, null); + await result.writer.writev(new Set([true])); + result.writer.endSync(); + assert.strictEqual(await text(source), '42true'); + + let signalReads = 0; + const fromResult = Broadcast.from(from(''), { + get signal() { + signalReads++; + return undefined; + }, + }); + assert.strictEqual(signalReads, 1); + fromResult.broadcast.cancel(); +} + +Promise.all([ + testDictionaryAndIntegerConversion(), + testUnknownDictionaryMembers(), + testWriterConversion(), + testOtherWriterConversions(), +]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-writable-interop.js b/test/parallel/test-stream-iter-writable-interop.js index 110e8e0a7d2e..a8ae0a99cad7 100644 --- a/test/parallel/test-stream-iter-writable-interop.js +++ b/test/parallel/test-stream-iter-writable-interop.js @@ -511,25 +511,18 @@ async function testAsyncDispose() { } // ============================================================================= -// write() validates chunk type +// write() rejects values that cannot be converted to USVString // ============================================================================= -async function testWriteInvalidChunkType() { +function testWriteInvalidChunkType() { const writable = new Writable({ write(chunk, enc, cb) { cb(); } }); const writer = fromWritable(writable); - await assert.rejects( - writer.write(42), - { code: 'ERR_INVALID_ARG_TYPE' }, - ); - await assert.rejects( - writer.write(null), - { code: 'ERR_INVALID_ARG_TYPE' }, - ); - await assert.rejects( - writer.write({}), + assert.throws( + () => writer.write(Symbol('invalid')), { code: 'ERR_INVALID_ARG_TYPE' }, ); + writable.destroy(); } // ============================================================================= @@ -559,7 +552,7 @@ function testWritevInvalidChunkUncorks() { const writer = fromWritable(writable); assert.throws( - () => writer.writev([new Uint8Array([1]), 42]), + () => writer.writev([new Uint8Array([1]), Symbol('invalid')]), { code: 'ERR_INVALID_ARG_TYPE' }, ); assert.strictEqual(writable.writableCorked, 0);