diff --git a/doc/api/fs.md b/doc/api/fs.md index d6eb4208ef0a..7feb4026f5de 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -412,8 +412,9 @@ Return the file contents as an async iterable using the chunks (default 128 KB). If transforms are provided, they are applied via [`stream/iter pull()`][]. -The file handle is locked while the iterable is being consumed and unlocked -when iteration completes, an error occurs, or the consumer breaks. +The file handle is locked from the first read of the iterable, and unlocked +when iteration completes, an error occurs, or the consumer breaks. An iterable +that is never read does not lock the file handle. This function is only available when the `--experimental-stream-iter` flag is enabled. @@ -487,8 +488,8 @@ Synchronous counterpart of [`filehandle.pull()`][]. Returns a sync iterable that reads the file using synchronous I/O on the main thread. Reads are performed in `chunkSize`-byte chunks (default 128 KB). -The file handle is locked while the iterable is being consumed. Unlike the -async `pull()`, this method does not support `AbortSignal` since all +The file handle is locked from the first read of the iterable until iteration +ends, as with [`filehandle.pull()`][]. Unlike the async `pull()`, this method does not support `AbortSignal` since all operations are synchronous. This function is only available when the `--experimental-stream-iter` flag is @@ -1134,6 +1135,11 @@ The writer supports both `Symbol.asyncDispose` and `Symbol.dispose`: for it to complete. * `using w = fh.writer()` — calls `fail()` unconditionally. +Async writes (`write()` and `writev()`) that are started without awaiting the +previous one are performed one at a time, in the order they were called, so +they never overlap in the file. A queued write is not performed if the writer +fails, or its `signal` aborts, before its turn. + The `writeSync()` and `writevSync()` methods enable the try-sync fast path used by [`stream/iter pipeTo()`][]. When the reader's chunk size matches the writer's `chunkSize`, all writes in a `pipeTo()` pipeline complete diff --git a/doc/api/stream_iter.md b/doc/api/stream_iter.md index 44a6d848a218..a2d79e44695a 100644 --- a/doc/api/stream_iter.md +++ b/doc/api/stream_iter.md @@ -18,6 +18,13 @@ functions or objects with a `transform` method. Data flows in **batches** ({Uint8Array\[]} per iteration) to amortize the cost of async operations. +The module implements the WinterTC [Iterable Streams API][] draft. The +classic stream interop functions ([`fromReadable()`][], [`fromWritable()`][], +[`toReadable()`][], [`toReadableSync()`][] and [`toWritable()`][]), +[`Broadcast.from()`][], [`Share.from()`][], [`SyncShare.fromSync()`][] and the +protocol symbols exported by `Stream` are Node.js extensions that are not part +of the draft. + ```mjs import { from, pull, text } from 'node:stream/iter'; import { compressGzip, decompressGzip } from 'node:zlib/iter'; @@ -112,6 +119,10 @@ async function run() { } ``` +Some iterators of this module return iterator results (`{ done, value }` +objects) that do not inherit from `Object.prototype`. Code should only rely on +their `done` and `value` properties, as `for await...of` does. + ### Transforms Transforms come in two forms: @@ -133,6 +144,12 @@ Both forms receive an `options` parameter with the following property: can check `signal.aborted` or listen for the `'abort'` event to perform early cleanup. +In `pull()`, stateless transforms receive a new `options` object for every +call, and stateful transforms one for the pipeline, so a transform can modify +its `options` without affecting other transforms. The object does not inherit +from `Object.prototype`. Transforms passed to [`pullSync()`][] receive no +`options`. + The flush signal (`null`) is sent after the source ends, giving transforms a chance to emit trailing data (e.g., compression footers). @@ -407,6 +424,18 @@ converted to a `USVString` and then UTF-8 encoded. `writev()` and chunks. Writer option dictionaries treat `null` as an empty dictionary and ignore unknown members. +Arguments are converted before the write itself starts. If the conversion runs +user code (for example a `toString()` method, or the iterator of a `writev()` +argument) that writes to the same writer, those writes are ordered before the +write whose argument is being converted, and they count against the same +backpressure limits. + +After `end()` or `endSync()` has been called, and until all buffered data has +been consumed, the writer is _closing_. While closing, `canWrite` is `null`, +`write()` and `writev()` reject with a `TypeError`, `writeSync()` and +`writevSync()` return `false`, `endSync()` returns `-1`, and calling `end()` +again returns the same promise as the first call. + 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 @@ -573,6 +602,10 @@ Objects implementing `Symbol.for('Stream.toAsyncStreamable')` or precedence over the iteration protocols (`Symbol.asyncIterator`, `Symbol.iterator`). +The readable of a [`push()`][] stream without transforms and the iterables +returned by [`fromReadable()`][] already yield normalized batches, so `from()` +returns them unchanged. + ```mjs import { Buffer } from 'node:buffer'; import { from, text } from 'node:stream/iter'; @@ -695,8 +728,12 @@ added: * `source` {Iterable} The sync data source. * `...transforms` {Function|Object} Zero or more sync transforms. -* `writer` {Object} Destination with `write(chunk)` method. +* `writer` {Object} Destination with a `writeSync(chunk)` method. * `options` {Object} + * `failOnIncompleteClose` {boolean} If `true`, call `writer.fail()` when + `writer.endSync()` cannot close the writer synchronously. Ignored when + `preventFail` is `true`. This option is a Node.js extension. + **Default:** `false`. * `preventClose` {boolean} **Default:** `false`. * `preventFail` {boolean} **Default:** `false`. * Returns: {number} Total bytes written. @@ -704,8 +741,20 @@ added: Synchronous version of [`pipeTo()`][]. The `source`, all transforms, and the `writer` must be synchronous. Cannot accept async iterables or promises. -The `writer` must have the `*Sync` methods (`writeSync`, `writevSync`, -`endSync`) and `fail()` for this to work. +The `writer` must have a `writeSync()` method. The other methods are +optional: `writevSync()` is used for batches of more than one chunk if it is +present, `endSync()` is called to close the writer (unless `preventClose` is +`true`), and `fail()` is called if the pipe fails (unless `preventFail` is +`true`). A writer without `endSync()` is not closed. + +`pipeToSync()` never falls back to the asynchronous writer methods. If +`writer.endSync()` returns `-1` because the writer cannot close synchronously +(for example, a `push()` writer whose consumer has not read all of the data +yet), `pipeToSync()` throws `ERR_INVALID_STATE`. All of the data was accepted +by then, so by default the writer is not failed: it can still be closed, for +example with `await writer.end()`. If the writer cannot be closed any other +way (for example, it has no `end()` method), or the caller will not close it, +set `failOnIncompleteClose` to fail it with the thrown error instead. ### `pull(source[, ...transforms][, options])` @@ -723,8 +772,12 @@ added: 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. +until the returned iterable is consumed. Transforms are applied in order. + +When `signal` aborts, the pending read (or the next one) rejects with +`signal.reason`, and so does every later read. If `signal` is already aborted, +`pull()` still returns an iterable; reading from it rejects with +`signal.reason` without reading from `source`. ```mjs import { from, pull, text } from 'node:stream/iter'; @@ -811,7 +864,7 @@ added: readable side. * `options` {Object} * `budget` {number} Maximum number of buffered bytes before - backpressure is applied. Must be >= 16384. + backpressure is applied. Must be a positive integer. **Default:** `16384`. * `backpressure` {string} Backpressure policy: `'strict'`, `'unbounded'`, `'drop-oldest'`, or `'drop-newest'`. **Default:** `'strict'`. @@ -865,6 +918,10 @@ run().catch(console.error); The writer returned by `push()` conforms to the \[Writer interface]\[]. +Zero-length chunks are accepted without being buffered: they are not delivered +to the consumer, and `writeSync()` and `write()` report success for them even +when backpressure is active. + ## Duplex channels ### `duplex([options])` @@ -877,7 +934,7 @@ added: * `options` {Object} * `budget` {number} Buffer size in bytes for both directions. - Must be >= 16384. **Default:** `16384`. + Must be a positive integer. **Default:** `16384`. * `backpressure` {string} Policy for both directions. **Default:** `'strict'`. * `signal` {AbortSignal} Cancellation signal for both channels. @@ -1196,7 +1253,12 @@ added: Merge multiple async iterables by yielding batches in temporal order (whichever source produces data first). All sources are consumed -concurrently. +concurrently, with at most one pending `next()` call per source. + +If a source fails, the returned iterable rejects with its error. `merge()` +calls `return()` on the other sources but does not wait for it to settle: an +async generator source that is suspended in an `await` only runs its cleanup +once that `await` completes. ```mjs import { from, merge, text } from 'node:stream/iter'; @@ -1224,8 +1286,9 @@ added: - v24.20.0 --> -* `callback` {Function} `(chunks) => void` Called with each batch and with - `null` when the source ends. +* `callback` {Function} `(chunks, options) => void` Called with each batch and + with `null` when the source ends. `options.signal` is the pipeline's + {AbortSignal}. * Returns: {Function} A stateless transform. Create a pass-through transform that observes batches without modifying them. @@ -1286,7 +1349,7 @@ added: --> * `options` {Object} - * `budget` {number} Buffer size in bytes. Must be >= 16384. + * `budget` {number} Buffer size in bytes. Must be a positive integer. **Default:** `65536`. * `backpressure` {string} `'strict'`, `'unbounded'`, `'drop-oldest'`, or `'drop-newest'`. **Default:** `'strict'`. @@ -1354,6 +1417,10 @@ run().catch(console.error); Cancel the broadcast. If `reason` is provided, all consumers reject with that exact reason. If it is omitted, consumers complete normally. +Cancelling also closes the paired writer: afterwards its `canWrite` is `null` +and `write()` rejects with a `TypeError`. This lets a [`Broadcast.from()`][] +pump stop pulling from its source. + #### `broadcast.consumerCount` * {number} @@ -1400,7 +1467,7 @@ added: * `source` {AsyncIterable} The source to share. * `options` {Object} - * `budget` {number} Buffer size in bytes. Must be >= 16384. + * `budget` {number} Buffer size in bytes. Must be a positive integer. **Default:** `65536`. * `backpressure` {string} `'strict'`, `'unbounded'`, `'drop-oldest'`, or `'drop-newest'`. **Default:** `'strict'`. @@ -1411,6 +1478,28 @@ Create a pull-model multi-consumer shared stream. Unlike `broadcast()`, the source is only read when a consumer pulls. Multiple consumers share a single buffer. +A consumer created with `share.pull()` starts reading at the oldest entry still +in the buffer. Entries are released once every consumer has read them. When +every consumer has detached, the buffered data is kept for consumers that +attach later, and the source is not closed. Call `share.cancel()` (or dispose +the share) to release the source once it is no longer needed. + +With `'strict'` backpressure, a consumer that needs to pull from the source +while the buffer is at or above `budget` is rejected with `ERR_OUT_OF_RANGE` +and detached; further reads from that consumer complete with `{ done: true }`. +Detaching keeps a consumer that is not retried (for example, one read with +`for await...of`, which does not call `return()` when a read rejects) from +holding buffered data and blocking the other consumers. + +With `'unbounded'`, such a consumer waits until the slowest consumer releases +budget. With `'drop-newest'`, the entry pulled from the source is discarded +and the consumer then waits in the same way, so in both cases a stalled +consumer also stalls the consumers that are ahead of it. Only `'drop-oldest'` +lets consumers that are ahead continue, by discarding the oldest buffered +entries that the slowest consumer has not read yet. A batch pulled from the +source that is larger than `budget` is split into smaller entries first, so +eviction keeps the newest chunks that fit within the budget. + ```mjs import { from, share, text } from 'node:stream/iter'; @@ -1506,20 +1595,18 @@ added: * `source` {Iterable} The sync source to share. * `options` {Object} - * `budget` {number} Must be >= 16384. + * `budget` {number} Must be a positive integer. **Default:** `65536`. - * `backpressure` {string} `'strict'`, `'drop-oldest'`, or `'drop-newest'`. + * `backpressure` {string} `'strict'` or `'drop-oldest'`. **Default:** `'strict'`. * Returns: {SyncShare} Synchronous version of [`share()`][]. -Because there is no way to wait in a synchronous context, `'unbounded'` is not -supported and throws `ERR_INVALID_ARG_VALUE`. With `'drop-newest'`, a consumer -that reaches the end of the buffer while the budget is exhausted discards a -single entry from the source and then returns `{ done: true }` without a -value; the consumer is not detached, so it can resume once the slowest -consumer advances and releases budget. +A synchronous consumer cannot wait for the slowest consumer to release budget, +and the slowest consumer cannot advance while another consumer's read is +running. `'unbounded'` and `'drop-newest'` are therefore not supported and +throw `ERR_INVALID_ARG_VALUE`. ### Class: `SyncShare` @@ -2243,20 +2330,30 @@ const stream = fromSync(new Greeting('world')); console.log(textSync(stream)); // 'hello world' ``` +[Iterable Streams API]: https://iter-streams.proposal.wintertc.org/ [`--experimental-stream-iter`]: cli.md#--experimental-stream-iter +[`Broadcast.from()`]: #broadcastfrominput-options +[`Share.from()`]: #static-method-sharefrominput-options +[`SyncShare.fromSync()`]: #static-method-syncsharefromsyncinput-options [`array()`]: #arraysource-options [`arrayBuffer()`]: #arraybuffersource-options [`bytes()`]: #bytessource-options [`from()`]: #frominput +[`fromReadable()`]: #fromreadablereadable [`fromSync()`]: #fromsyncinput +[`fromWritable()`]: #fromwritablewritable-options [`node:zlib/iter`]: zlib.md#iterable-compression [`ondrain()`]: #ondraindrainable [`pipeTo()`]: #pipetosource-transforms-writer-options [`pull()`]: #pullsource-transforms-options [`pullSync()`]: #pullsyncsource-transforms +[`push()`]: #pushtransforms-options [`share()`]: #sharesource-options [`stream.Readable`]: stream.md#class-streamreadable [`stream.Writable`]: stream.md#class-streamwritable [`tap()`]: #tapcallback [`text()`]: #textsource-options [`toAsyncStreamable`]: #streamtoasyncstreamable +[`toReadable()`]: #toreadablesource-options +[`toReadableSync()`]: #toreadablesyncsource-options +[`toWritable()`]: #towritablewriter diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 0e80f682817d..ddd07a98209d 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1848,7 +1848,7 @@ E('ERR_STREAM_UNABLE_TO_PIPE', 'Cannot pipe to a closed or destroyed stream', Er E('ERR_STREAM_UNSHIFT_AFTER_END_EVENT', 'stream.unshift() after end event', Error); E('ERR_STREAM_WRAP', 'Stream has StringDecoder set or is in objectMode', Error); -E('ERR_STREAM_WRITE_AFTER_END', 'write after end', Error); +E('ERR_STREAM_WRITE_AFTER_END', 'write after end', Error, TypeError); E('ERR_SYNTHETIC', 'JavaScript Callstack', Error); E('ERR_SYSTEM_ERROR', 'A system error occurred', SystemError, HideStackFramesError); E('ERR_TEST_FAILURE', function(error, failureType) { diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index efa981c55e31..c21b1e5740fc 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -480,6 +480,18 @@ if (getOptionValue('--experimental-stream-iter')) { const kNullPrototo = { __proto__: null }; const kDefaultChunkSize = 131072; const kNone = -1; + + // Called when iteration of a pull()/pullSync() iterable actually starts. + function lockForIteration(handle, fd) { + if (handle[kFd] === kNone || handle[kFd] !== fd) + throw new ERR_INVALID_STATE('The FileHandle is closed'); + if (handle[kClosePromise]) + throw new ERR_INVALID_STATE('The FileHandle is closing'); + if (handle[kLocked]) + throw new ERR_INVALID_STATE('The FileHandle is locked'); + handle[kLocked] = true; + } + /** * Return the file contents as an AsyncIterable using the * new streams pull model. Optional transforms and options (including @@ -527,11 +539,13 @@ if (getOptionValue('--experimental-stream-iter')) { validateAbortSignal(signal, 'options.signal'); } - this[kLocked] = true; - const source = { __proto__: null, async *[SymbolAsyncIterator]() { + // The handle is locked only while the iterable is actually being + // consumed. Locking eagerly in pull() would leave the handle locked + // forever if the returned iterable were never iterated. + lockForIteration(handle, fd); handle[kRef](); try { if (signal) { @@ -641,10 +655,6 @@ if (getOptionValue('--experimental-stream-iter')) { validateInteger(readSize, 'options.chunkSize', 1); } - this[kLocked] = true; - - handle[kRef](); - function cleanup() { handle[kLocked] = false; handle[kUnref](); @@ -656,15 +666,24 @@ if (getOptionValue('--experimental-stream-iter')) { const source = { __proto__: null, [SymbolIterator]() { + let started = false; let done = false; return { __proto__: null, next() { - if (done || remaining === 0) { - if (!done) { - done = true; - cleanup(); - } + if (done) { + return { done: true, value: undefined }; + } + if (!started) { + // Lock lazily, on the first read, so that an iterable that is + // never consumed does not leave the handle locked forever. + lockForIteration(handle, fd); + handle[kRef](); + started = true; + } + if (remaining === 0) { + done = true; + cleanup(); return { done: true, value: undefined }; } const toRead = remaining > 0 ? @@ -692,7 +711,7 @@ if (getOptionValue('--experimental-stream-iter')) { return() { if (!done) { done = true; - cleanup(); + if (started) cleanup(); } return { done: true, value: undefined }; }, @@ -751,6 +770,11 @@ if (getOptionValue('--experimental-stream-iter')) { let asyncPending = 0; let released = false; const pendingWrites = new SafeSet(); + // Async writes are issued one at a time, in call order. Without this, + // overlapping write() calls would race on the file position: when no + // `start` is given every write targets the fd's current (shared) + // position, and partial writes are completed in follow-up syscalls. + let writeQueue = PromiseResolve(); validateBoolean(autoClose, 'options.autoClose'); @@ -866,6 +890,22 @@ if (getOptionValue('--experimental-stream-iter')) { }); } + function ignoreWriteQueueResult() {} + + // Run `start` once every previously queued write has settled. + function enqueueWrite(start, signal) { + const operation = PromisePrototypeThen(writeQueue, () => { + // A failed writer must not touch the file again, and a write whose + // signal aborted while it was queued must not be started. + if (errored) throw error; + signal?.throwIfAborted(); + return start(); + }); + writeQueue = PromisePrototypeThen(operation, ignoreWriteQueueResult, + ignoreWriteQueueResult); + return operation; + } + function trackOperation(operation) { const { promise, resolve, reject } = PromiseWithResolvers(); const pending = { __proto__: null, reject }; @@ -950,8 +990,9 @@ if (getOptionValue('--experimental-stream-iter')) { if (bytesRemaining > 0) bytesRemaining -= chunk.byteLength; const position = pos; if (pos >= 0) pos += chunk.byteLength; - return trackOperation( - writeAll(chunk, 0, chunk.byteLength, position, signal)); + return trackOperation(enqueueWrite( + () => writeAll(chunk, 0, chunk.byteLength, position, signal), + signal)); }, writev(chunks, options = kNullPrototo) { @@ -983,7 +1024,8 @@ if (getOptionValue('--experimental-stream-iter')) { if (bytesRemaining > 0) bytesRemaining -= totalSize; const position = pos; if (pos >= 0) pos += totalSize; - return trackOperation(writevAll(chunks, position, signal)); + return trackOperation(enqueueWrite( + () => writevAll(chunks, position, signal), signal)); }, writeSync(chunk) { diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 4922ce562751..df3649010c74 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -2324,7 +2324,7 @@ class QuicStream { async function writeAsync(chunk, signal) { if (errored) throw error; if (closed || ending || stream.#inner.state.writeEnded) { - throw new ERR_INVALID_STATE('Writer is closed'); + throw new ERR_INVALID_STATE.TypeError('Writer is closed'); } signal?.throwIfAborted(); @@ -2350,7 +2350,7 @@ class QuicStream { await waitForDrain(signal); if (errored) throw error; if (closed || stream.#inner.state.writeEnded) { - throw new ERR_INVALID_STATE('Writer is closed'); + throw new ERR_INVALID_STATE.TypeError('Writer is closed'); } signal?.throwIfAborted(); if (writeConverted(chunks, token)) return; @@ -2394,7 +2394,7 @@ class QuicStream { async function writevAsync(chunks, signal) { if (errored) throw error; if (closed || ending || stream.#inner.state.writeEnded) { - throw new ERR_INVALID_STATE('Writer is closed'); + throw new ERR_INVALID_STATE.TypeError('Writer is closed'); } signal?.throwIfAborted(); diff --git a/lib/internal/streams/iter/broadcast.js b/lib/internal/streams/iter/broadcast.js index e32f8aa544d2..e107927730ce 100644 --- a/lib/internal/streams/iter/broadcast.js +++ b/lib/internal/streams/iter/broadcast.js @@ -9,7 +9,6 @@ const { ArrayIsArray, ArrayPrototypePush, - ArrayPrototypeShift, FunctionPrototypeCall, ObjectSetPrototypeOf, PromisePrototypeThen, @@ -40,9 +39,6 @@ const { ERR_INVALID_STATE, }, } = require('internal/errors'); -const { - validateInteger, -} = require('internal/validators'); const { broadcastProtocol, @@ -60,7 +56,11 @@ const { } = require('internal/streams/iter/pull'); const { + IterResult, + PendingRequest, + PendingWrite, kMultiConsumerDefaultBudget, + kNullOnceOption, kResolvedPromise, convertChunks, createBatchEntry, @@ -72,6 +72,7 @@ const { toWriterUint8Array, validateBatchEntry, yieldAbortable, + validateBudget, } = require('internal/streams/iter/utils'); const { converters, @@ -98,7 +99,7 @@ function raceEndWithSignal(promise, signal) { const { promise: aborted, reject } = PromiseWithResolvers(); const onAbort = () => reject(signal.reason); - signal.addEventListener('abort', onAbort, { __proto__: null, once: true }); + signal.addEventListener('abort', onAbort, kNullOnceOption); if (signal.aborted) onAbort(); return SafePromisePrototypeFinally( @@ -192,7 +193,9 @@ class BroadcastImpl { cursor: this.#bufferStart, resolve: null, reject: null, - pending: [], + // Reads requested while another one is pending. Rarely used, so it + // starts small. + pending: new RingBuffer(1), detached: false, error: kNoBroadcastError, }, null); @@ -209,13 +212,13 @@ class BroadcastImpl { const self = this; const kDone = PromiseResolve( - { __proto__: null, done: true, value: undefined }); + new IterResult(true, undefined)); function detach() { state.detached = true; self.#waiters.delete(state); if (state.resolve) { - state.resolve({ __proto__: null, done: true, value: undefined }); + state.resolve(new IterResult(true, undefined)); } self.#resolvePendingDone(state); if (self.#deleteConsumer(state)) { @@ -227,8 +230,7 @@ class BroadcastImpl { return { __proto__: null, [SymbolAsyncIterator]() { - return { - __proto__: null, + return ObjectSetPrototypeOf({ next() { if (state.detached) { if (state.error !== kNoBroadcastError) { @@ -248,7 +250,7 @@ class BroadcastImpl { self.#tryTrimBuffer(); } return PromiseResolve( - { __proto__: null, done: false, value: chunk }); + new IterResult(false, chunk)); } if (self.#errored) { @@ -265,8 +267,7 @@ class BroadcastImpl { if (state.resolve) { const { promise, resolve, reject } = PromiseWithResolvers(); - ArrayPrototypePush(state.pending, - { __proto__: null, resolve, reject }); + state.pending.push(new PendingRequest(resolve, reject)); return promise; } @@ -286,7 +287,7 @@ class BroadcastImpl { detach(); return kDone; }, - }; + }, null); }, }; } @@ -310,7 +311,7 @@ class BroadcastImpl { if (hasReason) { consumer.reject?.(reason); } else { - consumer.resolve({ __proto__: null, done: true, value: undefined }); + consumer.resolve(new IterResult(true, undefined)); } consumer.resolve = null; consumer.reject = null; @@ -400,9 +401,9 @@ class BroadcastImpl { --this.#cachedMinCursorConsumers === 0) { this.#tryTrimBuffer(); } - consumer.resolve({ __proto__: null, done: false, value: chunk }); + consumer.resolve(new IterResult(false, chunk)); } else { - consumer.resolve({ __proto__: null, done: true, value: undefined }); + consumer.resolve(new IterResult(true, undefined)); this.#resolvePendingDone(consumer); consumer.detached = true; } @@ -532,7 +533,7 @@ class BroadcastImpl { const resolve = consumer.resolve; consumer.resolve = null; consumer.reject = null; - resolve({ __proto__: null, done: false, value: chunk }); + resolve(new IterResult(false, chunk)); if (consumer.detached && this.#deleteConsumer(consumer)) { this.#tryTrimBuffer(); } else if (this.#promotePending(consumer)) { @@ -562,7 +563,7 @@ class BroadcastImpl { } #promotePending(consumer) { - const next = ArrayPrototypeShift(consumer.pending); + const next = consumer.pending.shift(); if (next === undefined) return false; consumer.resolve = next.resolve; consumer.reject = next.reject; @@ -575,14 +576,14 @@ class BroadcastImpl { consumer.reject = null; } while (consumer.pending.length > 0) { - ArrayPrototypeShift(consumer.pending).resolve( - { __proto__: null, done: true, value: undefined }); + consumer.pending.shift().resolve( + new IterResult(true, undefined)); } } #rejectPending(consumer, reason) { while (consumer.pending.length > 0) { - ArrayPrototypeShift(consumer.pending).reject(reason); + consumer.pending.shift().reject(reason); } } } @@ -627,7 +628,7 @@ class BroadcastWriter { if (canWrite === null) return null; if (canWrite) return PromiseResolve(true); const { promise, resolve, reject } = PromiseWithResolvers(); - ArrayPrototypePush(this.#pendingDrains, { __proto__: null, resolve, reject }); + ArrayPrototypePush(this.#pendingDrains, new PendingRequest(resolve, reject)); return promise; } @@ -804,7 +805,7 @@ class BroadcastWriter { */ #createPendingWrite(batch, signal) { const { promise, resolve, reject } = PromiseWithResolvers(); - const entry = { __proto__: null, batch, resolve, reject }; + const entry = new PendingWrite(batch, resolve, reject); this.#pendingWrites.push(entry); if (signal) { wireBroadcastWriteSignal(entry, signal, resolve, reject, this); @@ -838,6 +839,7 @@ class BroadcastWriter { } #resolvePendingDrains(canWrite) { + if (this.#pendingDrains.length === 0) return; const drains = this.#pendingDrains; this.#pendingDrains = []; for (let i = 0; i < drains.length; i++) { @@ -846,6 +848,7 @@ class BroadcastWriter { } #rejectPendingDrains(error) { + if (this.#pendingDrains.length === 0) return; const drains = this.#pendingDrains; this.#pendingDrains = []; for (let i = 0; i < drains.length; i++) { @@ -873,7 +876,7 @@ function wireBroadcastWriteSignal(entry, signal, resolve, reject, self) { entry.batch = null; reject(reason); }; - signal.addEventListener('abort', onAbort, { __proto__: null, once: true }); + signal.addEventListener('abort', onAbort, kNullOnceOption); } // ============================================================================= @@ -895,7 +898,7 @@ function broadcast(options = { __proto__: null }) { backpressure = 'strict', signal, } = options; - validateInteger(budget, 'options.budget', 16384); + validateBudget(budget); const opts = { __proto__: null, diff --git a/lib/internal/streams/iter/classic.js b/lib/internal/streams/iter/classic.js index 59eb5be5819b..2b39bc927874 100644 --- a/lib/internal/streams/iter/classic.js +++ b/lib/internal/streams/iter/classic.js @@ -14,6 +14,7 @@ const { ArrayPrototypePush, FunctionPrototypeCall, + ObjectFreeze, Promise, PromisePrototypeThen, PromiseReject, @@ -61,6 +62,8 @@ const { } = require('internal/streams/iter/types'); const { + PendingRequest, + kNullOnceOption, convertChunks, getWriterSignal, onSignalAbort, @@ -101,10 +104,7 @@ function raceWithSignal(promise, signal) { reject, } = PromiseWithResolvers(); const onAbort = () => reject(signal.reason); - signal.addEventListener('abort', onAbort, { - __proto__: null, - once: true, - }); + signal.addEventListener('abort', onAbort, kNullOnceOption); PromisePrototypeThen( promise, (value) => { @@ -160,14 +160,14 @@ async function normalizeBatch(raw) { for (let i = 0; i < raw.length; i++) { const value = raw[i]; if (isUint8Array(value)) { - ArrayPrototypePush(batch, value); + batch[batch.length] = value; } else { // normalizeAsyncValue may await for async protocols (e.g. // toAsyncStreamable on yielded objects). Stream events during // the suspension are queued, not lost -- errors will surface // on the next loop iteration after this yield completes. for await (const normalized of normalizeAsyncValue(value)) { - ArrayPrototypePush(batch, normalized); + batch[batch.length] = normalized; } } } @@ -229,7 +229,7 @@ function createBatchedAsyncIterator(stream, normalize) { stream._readableState?.length > 0) { const c = stream.read(); if (c === null) break; - ArrayPrototypePush(batch, c); + batch[batch.length] = c; } if (normalize !== null) { const result = await normalize(batch); @@ -522,6 +522,18 @@ function toReadableSync(source, options = kNullPrototype) { // Cache: one Writer adapter per Writable instance. const fromWritableCache = new SafeWeakMap(); +// A write queued by fromWritable() until the writable can accept it. Like +// the other per-write records, constructed rather than created as a +// `{ __proto__: null, ... }` literal (a dictionary-mode object). +function QueuedWrite(chunks, resolve, reject) { + this.chunks = chunks; + this.resolve = resolve; + this.reject = reject; + this.signal = undefined; + this.onAbort = undefined; +} +QueuedWrite.prototype = ObjectFreeze({ __proto__: null }); + /** * Create a stream/iter Writer adapter from a classic Writable (or duck-type). * @@ -607,7 +619,9 @@ function fromWritable(writable, options = kNullPrototype) { } function removeDrainListenerIfIdle() { + // Keep listening while needsDrain is set: it is only cleared by 'drain'. if (!drainListenerInstalled || + needsDrain || pendingWrites.length !== 0 || drainWaiters.length !== 0) { return; @@ -643,7 +657,7 @@ function fromWritable(writable, options = kNullPrototype) { if (preserveReason) { pending[i].reject(reason); } else { - pending[i].close(); + pending[i].resolve(false); } } @@ -687,7 +701,12 @@ function fromWritable(writable, options = kNullPrototype) { for (let i = 0; i < chunks.length; i++) { const bytes = chunks[i]; if (!writable.write(bytes)) { + // Listen for 'drain' now, even if nothing is waiting yet: the + // Writable can emit it before the next write or wait (for example + // when its write callback runs on a microtask), and needsDrain + // would never be cleared. needsDrain = true; + installDrainListener(); ok = false; } totalBytes += TypedArrayPrototypeGetByteLength(bytes); @@ -755,14 +774,7 @@ function fromWritable(writable, options = kNullPrototype) { function queueWrite(chunks, signal) { const { promise, resolve, reject } = PromiseWithResolvers(); - const entry = { - __proto__: null, - chunks, - resolve, - reject, - signal: undefined, - onAbort: undefined, - }; + const entry = new QueuedWrite(chunks, resolve, reject); pendingWrites.push(entry); installDrainListener(); @@ -912,7 +924,7 @@ function fromWritable(writable, options = kNullPrototype) { syncWritableError(); if (errored) return PromiseReject(error); if (!isWritable()) { - return PromiseReject(new ERR_STREAM_WRITE_AFTER_END()); + return PromiseReject(new ERR_STREAM_WRITE_AFTER_END.TypeError()); } if (signal?.aborted) return PromiseReject(signal.reason); @@ -944,7 +956,7 @@ function fromWritable(writable, options = kNullPrototype) { syncWritableError(); if (errored) return PromiseReject(error); if (!isWritable()) { - return PromiseReject(new ERR_STREAM_WRITE_AFTER_END()); + return PromiseReject(new ERR_STREAM_WRITE_AFTER_END.TypeError()); } if (signal?.aborted) return PromiseReject(signal.reason); if (chunks.length === 0) return PromiseResolve(); @@ -1038,12 +1050,7 @@ function fromWritable(writable, options = kNullPrototype) { return PromiseResolve(true); } const { promise, resolve, reject } = PromiseWithResolvers(); - ArrayPrototypePush(drainWaiters, { - __proto__: null, - resolve, - reject, - close() { resolve(false); }, - }); + ArrayPrototypePush(drainWaiters, new PendingRequest(resolve, reject)); installDrainListener(); return promise; }; diff --git a/lib/internal/streams/iter/consumers.js b/lib/internal/streams/iter/consumers.js index 9e9a8fde4ec0..8392be43084f 100644 --- a/lib/internal/streams/iter/consumers.js +++ b/lib/internal/streams/iter/consumers.js @@ -13,9 +13,9 @@ const { ArrayBufferPrototypeSlice, ArrayPrototypeMap, ArrayPrototypePush, - ArrayPrototypeShift, ArrayPrototypeSlice, FunctionPrototypeCall, + ObjectFreeze, Promise, PromisePrototypeThen, SafePromiseAllReturnVoid, @@ -36,6 +36,7 @@ const { }, } = require('internal/errors'); const { TextDecoder } = require('internal/encoding'); +const { RingBuffer } = require('internal/streams/iter/ringbuffer'); const { validateFunction, } = require('internal/validators'); @@ -52,10 +53,11 @@ const { } = require('internal/streams/iter/from'); const { + kNullOnceOption, concatBytes, - createBatchEntry, getProtocolMethod, - validateBatchEntry, + recordChunk, + validateRecordedChunks, yieldAbortable, } = require('internal/streams/iter/utils'); @@ -94,17 +96,6 @@ 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 @@ -114,23 +105,20 @@ function flattenBatchEntries(entries) { function collectSync(source, limit) { // Normalize source via fromSync() - accepts strings, ArrayBuffers, protocols, etc. const normalized = fromSync(source); - const entries = []; + const chunks = []; + const checks = []; let totalBytes = 0; for (const batch of normalized) { - 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); - } + for (let i = 0; i < batch.length; i++) { + totalBytes += recordChunk(chunks, checks, batch[i]); + if (limit !== undefined && totalBytes > limit) { + throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes); } } - ArrayPrototypePush(entries, entry); } - return flattenBatchEntries(entries); + return validateRecordedChunks(chunks, checks); } /** @@ -145,14 +133,17 @@ async function collectAsync(source, signal, limit) { // Normalize source via from() - accepts strings, ArrayBuffers, protocols, etc. const normalized = from(source); - const entries = []; + const chunks = []; + const checks = []; // Fast path: no signal and no limit if (!signal && limit === undefined) { for await (const batch of normalized) { - ArrayPrototypePush(entries, createBatchEntry(batch)); + for (let i = 0; i < batch.length; i++) { + recordChunk(chunks, checks, batch[i]); + } } - return flattenBatchEntries(entries); + return validateRecordedChunks(chunks, checks); } // Slow path: with signal or limit checks @@ -161,19 +152,15 @@ async function collectAsync(source, signal, limit) { for await (const batch of iterable) { signal?.throwIfAborted(); - 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); - } + for (let i = 0; i < batch.length; i++) { + totalBytes += recordChunk(chunks, checks, batch[i]); + if (limit !== undefined && totalBytes > limit) { + throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes); } } - ArrayPrototypePush(entries, entry); } - return flattenBatchEntries(entries); + return validateRecordedChunks(chunks, checks); } /** @@ -404,6 +391,16 @@ function ondrain(drainable) { const kNoMergeError = Symbol('kNoMergeError'); +// An entry in merge()'s ready queue: a value from `iterator`, or, for a +// source that failed, `reason` with no iterator. Created for every merged +// chunk, so constructed rather than created as a dictionary-mode literal. +function MergeEntry(iterator, value, reason) { + this.iterator = iterator; + this.value = value; + this.reason = reason; +} +MergeEntry.prototype = ObjectFreeze({ __proto__: null }); + /** * Merge multiple async iterables by yielding values in temporal order. * @param {...(AsyncIterable|object)} args @@ -451,7 +448,7 @@ function merge(...args) { // between consumer pulls are drained synchronously without an extra // 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 ready = new RingBuffer(); const pendingPulls = new SafeSet(); let activeCount = normalized.length; let waitResolve = null; @@ -465,10 +462,7 @@ function merge(...args) { waitResolve = null; } }; - signal.addEventListener('abort', onAbort, { - __proto__: null, - once: true, - }); + signal.addEventListener('abort', onAbort, kNullOnceOption); } // Called when a source's .next() settles. Pushes the result into @@ -479,12 +473,7 @@ function merge(...args) { if (result.done) { activeCount--; } else { - ArrayPrototypePush(ready, { - __proto__: null, - kind: 'value', - iterator, - value: result.value, - }); + ready.push(new MergeEntry(iterator, result.value, undefined)); } if (waitResolve) { waitResolve(); @@ -495,11 +484,7 @@ function merge(...args) { const onRejected = (iterator, reason) => { pendingPulls.delete(iterator); if (stopped) return; - ArrayPrototypePush(ready, { - __proto__: null, - kind: 'error', - reason, - }); + ready.push(new MergeEntry(undefined, undefined, reason)); if (waitResolve) { waitResolve(); waitResolve = null; @@ -526,8 +511,8 @@ function merge(...args) { // Drain ready queue synchronously while (ready.length > 0) { - const item = ArrayPrototypeShift(ready); - if (item.kind === 'error') { + const item = ready.shift(); + if (item.iterator === undefined) { throw item.reason; } yield item.value; diff --git a/lib/internal/streams/iter/duplex.js b/lib/internal/streams/iter/duplex.js index 95a117d81419..ce5ccadd060d 100644 --- a/lib/internal/streams/iter/duplex.js +++ b/lib/internal/streams/iter/duplex.js @@ -18,6 +18,7 @@ const { const { converters, } = require('internal/streams/iter/webidl'); +const { kNullOnceOption } = require('internal/streams/iter/utils'); /** * Create a pair of connected duplex channels for bidirectional communication. @@ -66,8 +67,7 @@ function duplex(options = { __proto__: null }) { if (signal.aborted) { abortBoth(); } else { - signal.addEventListener('abort', abortBoth, - { __proto__: null, once: true }); + signal.addEventListener('abort', abortBoth, kNullOnceOption); } } diff --git a/lib/internal/streams/iter/from.js b/lib/internal/streams/iter/from.js index cac8fd3565b6..759a89bb9a79 100644 --- a/lib/internal/streams/iter/from.js +++ b/lib/internal/streams/iter/from.js @@ -9,16 +9,17 @@ const { ArrayBufferIsView, ArrayIsArray, ArrayPrototypeEvery, - ArrayPrototypePush, ArrayPrototypeSlice, DataViewPrototypeGetBuffer, DataViewPrototypeGetByteLength, DataViewPrototypeGetByteOffset, FunctionPrototypeCall, + ObjectFreeze, + ObjectSetPrototypeOf, PromisePrototypeThen, + PromiseReject, PromiseResolve, PromiseWithResolvers, - SafePromiseRace, Symbol, SymbolAsyncIterator, SymbolIterator, @@ -52,6 +53,12 @@ const { } = require('internal/streams/iter/types'); const { + IterResult, + kActive, + kDone, + kResolvedPromise, + kStart, + createOperationQueue, getProtocolMethod, toUint8Array, } = require('internal/streams/iter/utils'); @@ -60,16 +67,19 @@ const { // Bounds peak memory when arrays flow through transforms, which must // allocate output for the entire batch at once. const FROM_BATCH_SIZE = 128; -const kNormalizationCancelled = Symbol('kNormalizationCancelled'); +// Yielded by normalizeAsyncValue() (only when `emitFlush` is true) right +// before it waits on a promise or on a nested async iterable. Callers that +// batch chunks yield whatever they have collected so far, so that chunks that +// are already available are not held back until the wait completes. +const kFlushBatch = Symbol('kFlushBatch'); function createNormalizationContext() { - return { - __proto__: null, + return ObjectSetPrototypeOf({ cancelled: false, reason: undefined, resolve: null, suppressCleanup: false, - }; + }, null); } function cancelNormalization(context, reason, suppressCleanup = false) { @@ -77,38 +87,89 @@ function cancelNormalization(context, reason, suppressCleanup = false) { context.cancelled = true; context.reason = reason; context.suppressCleanup = suppressCleanup; - context.resolve?.(kNormalizationCancelled); + context.resolve?.(); } function throwIfNormalizationCancelled(context) { if (context?.cancelled) throw context.reason; } -async function waitForNormalization(value, context) { +/** + * Wait for `value`, but stop waiting if the normalization is cancelled. + * Settles with the first of: + * - `value` fulfilling: its value, or the cancellation reason if the + * normalization has been cancelled by then; + * - `value` rejecting: its rejection reason; + * - cancellation: the cancellation reason. + * This runs for every value of a normalized async source, so it uses a + * single promise and a single reaction on `value` rather than racing + * promises. + * @param {any} value + * @param {object} [context] + * @returns {Promise|any} + */ +function waitForNormalization(value, context) { if (context === undefined) return value; - const { promise, resolve } = PromiseWithResolvers(); + const { promise, resolve, reject } = PromiseWithResolvers(); + const onCancel = () => { + if (context.resolve === onCancel) context.resolve = null; + reject(context.reason); + }; + PromisePrototypeThen( + PromiseResolve(value), + (result) => { + if (context.resolve === onCancel) context.resolve = null; + if (context.cancelled) { + reject(context.reason); + } else { + resolve(result); + } + }, + (error) => { + if (context.resolve === onCancel) context.resolve = null; + reject(error); + }); if (context.cancelled) { - resolve(kNormalizationCancelled); + // Already cancelled: a `value` that has already settled still takes + // precedence, as its reaction above runs first. + PromisePrototypeThen(kResolvedPromise, onCancel); } else { - context.resolve = resolve; - } - try { - const result = await SafePromiseRace([ - PromiseResolve(value), - promise, - ]); - throwIfNormalizationCancelled(context); - return result; - } finally { - if (context.resolve === resolve) context.resolve = null; + context.resolve = onCancel; } + return promise; } +// The method of an iterator returned by from() for a sync iterable that reads +// the next batch synchronously when it can, for pipeTo(): see nextSyncBatch() +// in createSyncSourceNormalizer(). +const kNextSyncBatch = Symbol('kNextSyncBatch'); + function createNormalizationIterator(createIterator) { const context = createNormalizationContext(); const iterator = createIterator(context); - return { - __proto__: null, + if (iterator[kNextSyncBatch] !== undefined) { + return ObjectSetPrototypeOf({ + next(value) { + return FunctionPrototypeCall(iterator.next, iterator, value); + }, + return(value) { + cancelNormalization( + context, lazyDOMException('Aborted', 'AbortError')); + return FunctionPrototypeCall(iterator.return, iterator, value); + }, + throw(error) { + cancelNormalization(context, error, true); + return FunctionPrototypeCall(iterator.throw, iterator, error); + }, + [kNextSyncBatch]() { + return iterator[kNextSyncBatch](); + }, + [SymbolAsyncIterator]() { + return this; + }, + }, null); + } + return ObjectSetPrototypeOf({ next(value) { return FunctionPrototypeCall(iterator.next, iterator, value); }, @@ -124,7 +185,7 @@ function createNormalizationIterator(createIterator) { [SymbolAsyncIterator]() { return this; }, - }; + }, null); } function createNormalizationSource(createIterator) { @@ -303,12 +364,16 @@ function* normalizeSyncSource(source) { yield batch; batch = []; } - yield* yieldBoundedBatch(value); + if (value.length <= FROM_BATCH_SIZE) { + if (value.length !== 0) yield value; + } else { + yield* yieldBoundedBatch(value); + } continue; } // Fast path 2: value is a single Uint8Array (very common) if (isUint8Array(value)) { - ArrayPrototypePush(batch, value); + batch[batch.length] = value; if (batch.length === FROM_BATCH_SIZE) { yield batch; batch = []; @@ -322,7 +387,7 @@ function* normalizeSyncSource(source) { } let valueBatch = []; for (const chunk of normalizeSyncValue(value)) { - ArrayPrototypePush(valueBatch, chunk); + valueBatch[valueBatch.length] = chunk; if (valueBatch.length === FROM_BATCH_SIZE) { yield valueBatch; valueBatch = []; @@ -381,44 +446,99 @@ function yieldNormalizationAbortable(source, context) { } } - return { - __proto__: null, - async next() { + // Settle a pending next() with a result of the source's next(). Throws + // (to be handled by the caller) like the checks it replaces would. + function toIterResult(result) { + throwIfNormalizationCancelled(context); + if ((typeof result !== 'object' && typeof result !== 'function') || + result === null) { + throw new ERR_INVALID_RETURN_VALUE( + 'an object', 'iterator.next()', result); + } + if (result.done) { + reading = false; + throwIfNormalizationCancelled(context); + completed = true; + closed = true; + return new IterResult(true, result.value); + } + const value = result.value; + reading = false; + throwIfNormalizationCancelled(context); + return new IterResult(false, value); + } + + // Reject a pending next(), closing the source first if the + // normalization has been cancelled. + function rejectNext(reject, error) { + if (context.cancelled) { + PromisePrototypeThen(closeSource(true), () => { + reading = false; + reject(error); + }); + return; + } + reading = false; + reject(error); + } + + return ObjectSetPrototypeOf({ + // next() runs for every value of the source, so instead of being an + // async function awaiting waitForNormalization(), it waits for the + // source with a single promise, which a cancellation rejects + // directly. + next() { if (completed) { - return { __proto__: null, done: true, value: undefined }; + return PromiseResolve(new IterResult(true, undefined)); } - throwIfNormalizationCancelled(context); + if (context.cancelled) return PromiseReject(context.reason); reading = true; + const { promise, resolve, reject } = PromiseWithResolvers(); + let next; try { - const next = FunctionPrototypeCall(nextMethod, iterator); - const result = await waitForNormalization(next, context); - if ((typeof result !== 'object' && typeof result !== 'function') || - result === null) { - throw new ERR_INVALID_RETURN_VALUE( - 'an object', 'iterator.next()', result); - } - if (result.done) { - reading = false; - throwIfNormalizationCancelled(context); - completed = true; - closed = true; - return { __proto__: null, done: true, value: result.value }; - } - const value = result.value; - reading = false; - throwIfNormalizationCancelled(context); - return { __proto__: null, done: false, value }; + next = FunctionPrototypeCall(nextMethod, iterator); } catch (error) { - if (context.cancelled) await closeSource(true); - reading = false; - throw error; + rejectNext(reject, error); + return promise; } + // The first of the source's result and a cancellation settles + // next(); whichever comes later is ignored. + let settled = false; + const onCancel = () => { + if (settled) return; + settled = true; + if (context.resolve === onCancel) context.resolve = null; + rejectNext(reject, context.reason); + }; + context.resolve = onCancel; + PromisePrototypeThen( + PromiseResolve(next), + (result) => { + if (settled) return; + settled = true; + if (context.resolve === onCancel) context.resolve = null; + let iterResult; + try { + iterResult = toIterResult(result); + } catch (error) { + rejectNext(reject, error); + return; + } + resolve(iterResult); + }, + (error) => { + if (settled) return; + settled = true; + if (context.resolve === onCancel) context.resolve = null; + rejectNext(reject, error); + }); + return promise; }, async return(value) { await closeSource( context.suppressCleanup || (context.cancelled && reading)); - return { __proto__: null, done: true, value }; + return new IterResult(true, value); }, async throw(error) { await closeSource( @@ -428,7 +548,7 @@ function yieldNormalizationAbortable(source, context) { [SymbolAsyncIterator]() { return this; }, - }; + }, null); }, }; } @@ -444,14 +564,15 @@ function yieldNormalizationAbortable(source, context) { * @yields {Uint8Array} */ async function* normalizeAsyncValue( - value, allowNestedAsyncStreamables = true, context) { + value, allowNestedAsyncStreamables = true, context, emitFlush = false) { throwIfNormalizationCancelled(context); // Handle promises first if (isPromise(value)) { + if (emitFlush) yield kFlushBatch; const resolved = await waitForNormalization(value, context); yield* normalizeAsyncValue( - resolved, allowNestedAsyncStreamables, context); + resolved, allowNestedAsyncStreamables, context, emitFlush); return; } @@ -478,13 +599,15 @@ async function* normalizeAsyncValue( if (asyncStreamableMethod !== undefined) { const result = FunctionPrototypeCall(asyncStreamableMethod, value); if (isPromise(result)) { + if (emitFlush) yield kFlushBatch; yield* normalizeAsyncValue( await waitForNormalization(result, context), allowNestedAsyncStreamables, - context); + context, + emitFlush); } else { yield* normalizeAsyncValue( - result, allowNestedAsyncStreamables, context); + result, allowNestedAsyncStreamables, context, emitFlush); } return; } @@ -493,7 +616,8 @@ async function* normalizeAsyncValue( const streamableMethod = getProtocolMethod(value, toStreamable); if (streamableMethod !== undefined) { const result = FunctionPrototypeCall(streamableMethod, value); - yield* normalizeAsyncValue(result, allowNestedAsyncStreamables, context); + yield* normalizeAsyncValue( + result, allowNestedAsyncStreamables, context, emitFlush); return; } @@ -501,7 +625,7 @@ async function* normalizeAsyncValue( if (ArrayIsArray(value)) { for (let i = 0; i < value.length; i++) { yield* normalizeAsyncValue( - value[i], allowNestedAsyncStreamables, context); + value[i], allowNestedAsyncStreamables, context, emitFlush); } return; } @@ -510,8 +634,11 @@ async function* normalizeAsyncValue( // have both) if (isAsyncIterable(value)) { const iterable = yieldNormalizationAbortable(value, context); + if (emitFlush) yield kFlushBatch; for await (const item of iterable) { - yield* normalizeAsyncValue(item, allowNestedAsyncStreamables, context); + yield* normalizeAsyncValue( + item, allowNestedAsyncStreamables, context, emitFlush); + if (emitFlush) yield kFlushBatch; } return; } @@ -519,7 +646,8 @@ async function* normalizeAsyncValue( // Handle sync iterables if (isSyncIterable(value)) { for (const item of value) { - yield* normalizeAsyncValue(item, allowNestedAsyncStreamables, context); + yield* normalizeAsyncValue( + item, allowNestedAsyncStreamables, context, emitFlush); } return; } @@ -534,100 +662,465 @@ async function* normalizeAsyncValue( } /** - * Normalize an async streamable source, yielding batches of Uint8Array. - * @param {AsyncIterable|Iterable} source + * Normalize a value of an async source that is neither a Uint8Array nor a + * Uint8Array[] batch, yielding batches of Uint8Array. Chunks are batched, + * but whatever has been collected is yielded before waiting on async content + * (such as a nested async iterable), so data is not held back until it ends. + * @param {any} value + * @param {object} context + * @param {boolean} [allowNestedAsync] Whether nested async iterables and + * toAsyncStreamable are allowed (they are not in sync sources). * @yields {Uint8Array[]} */ -async function* normalizeAsyncSource(source, context) { - throwIfNormalizationCancelled(context); - - // Prefer async iteration if available - if (isAsyncIterable(source)) { - const iterable = yieldNormalizationAbortable(source, context); - for await (const value of iterable) { - // Fast path 1: value is already a Uint8Array[] batch - if (isUint8ArrayBatch(value)) { - if (value.length > 0) { - yield value; - } - continue; - } - // Fast path 2: value is a single Uint8Array (very common) - if (isUint8Array(value)) { - yield [value]; - continue; - } - // Slow path: normalize the value - let batch = []; - for await (const chunk of normalizeAsyncValue(value, true, context)) { - ArrayPrototypePush(batch, chunk); - if (batch.length === FROM_BATCH_SIZE) { - yield batch; - batch = []; - } - } +async function* normalizeAsyncSourceValue(value, context, + allowNestedAsync = true) { + let batch = []; + for await (const chunk of + normalizeAsyncValue(value, allowNestedAsync, context, true)) { + if (chunk === kFlushBatch) { if (batch.length > 0) { yield batch; + batch = []; } + continue; } - return; + batch[batch.length] = chunk; + if (batch.length === FROM_BATCH_SIZE) { + yield batch; + batch = []; + } + } + if (batch.length > 0) { + yield batch; } +} - // Fall back to sync iteration - batch sync values together with a bound. - if (isSyncIterable(source)) { - let batch = []; +/** + * Iterator normalizing an async iterable source into batches of Uint8Array. + * + * This is what an async generator looping over the source with for await + * would do, written out by hand: the source is read for every batch, and an + * async generator layer costs several promises and an async frame each + * time. Values that are already batches or Uint8Arrays (the common case) + * are handled with one promise per batch; any other value is normalized by + * normalizeAsyncSourceValue(). As with an async generator: + * - nothing happens until the first next(); + * - calls made while one is in progress are queued; + * - an error from the source ends the iteration without closing the source, + * and an error normalizing a value closes the source first; + * - return() closes the value being normalized and the source, propagating + * errors from closing them, and throw() closes them ignoring such errors. + * @param {AsyncIterable} source + * @param {object} context + * @returns {object} An object with next(), return() and throw(). + */ +function createAsyncSourceNormalizer(source, context) { + let state = kStart; + // The source iterator, from yieldNormalizationAbortable(). Its return() + // is an async function, so calling it cannot throw synchronously. + let iterator; + // The value being normalized: a sync iterator of the sub-batches of an + // oversized batch, or a normalizeAsyncSourceValue() generator. + let boundedBatches = null; + let valueBatches = null; + const { run, settled } = createOperationQueue(); + + function finish(result) { + settled(); + return result; + } - for (const value of source) { - throwIfNormalizationCancelled(context); - // Fast path 1: value is already a Uint8Array[] batch - if (isUint8ArrayBatch(value)) { - // Flush any accumulated batch first - if (batch.length > 0) { - yield batch; - batch = []; - } - yield* yieldBoundedBatch(value); - continue; + function fail(error) { + state = kDone; + settled(); + throw error; + } + + // Closing for a throw completion: wait, but ignore errors. + function closeQuietly(it) { + return PromisePrototypeThen(it.return(), undefined, () => {}); + } + + function onBodyError(error) { + // Like for await when its body throws: close the source, keep the error. + valueBatches = null; + boundedBatches = null; + state = kDone; + return PromisePrototypeThen( + closeQuietly(iterator), () => fail(error)); + } + + function onSourceResult(result) { + if (result.done) { + state = kDone; + return finish(new IterResult(true, undefined)); + } + try { + return handleValue(result.value); + } catch (error) { + return onBodyError(error); + } + } + + function handleValue(value) { + if (isUint8ArrayBatch(value)) { + if (value.length <= FROM_BATCH_SIZE) { + if (value.length === 0) return pullSource(); + return finish(new IterResult(false, value)); } - // Fast path 2: value is a single Uint8Array (very common) - if (isUint8Array(value)) { - ArrayPrototypePush(batch, value); - if (batch.length === FROM_BATCH_SIZE) { - yield batch; - batch = []; - } - continue; + boundedBatches = yieldBoundedBatch(value); + return pullValue(); + } + if (isUint8Array(value)) return finish(new IterResult(false, [value])); + valueBatches = normalizeAsyncSourceValue(value, context); + return pullValue(); + } + + function onValueResult(result) { + if (result.done) { + valueBatches = null; + return pullSource(); + } + return finish(new IterResult(false, result.value)); + } + + function pullSource() { + return PromisePrototypeThen(iterator.next(), onSourceResult, fail); + } + + function pullValue() { + if (boundedBatches !== null) { + const result = boundedBatches.next(); + if (!result.done) return finish(new IterResult(false, result.value)); + boundedBatches = null; + return pullSource(); + } + return PromisePrototypeThen( + valueBatches.next(), onValueResult, onBodyError); + } + + function doNext() { + if (state === kDone) { + return PromiseResolve(finish(new IterResult(true, undefined))); + } + if (state === kStart) { + try { + throwIfNormalizationCancelled(context); + const iterable = yieldNormalizationAbortable(source, context); + iterator = iterable[SymbolAsyncIterator](); + } catch (error) { + state = kDone; + settled(); + return PromiseReject(error); } - // Slow path: normalize the value - must flush and yield individually + state = kActive; + } + if (boundedBatches !== null || valueBatches !== null) { + return PromiseResolve(pullValue()); + } + return pullSource(); + } + + function doReturn(value) { + const result = new IterResult(true, value); + if (state !== kActive) { + state = kDone; + return PromiseResolve(finish(result)); + } + state = kDone; + boundedBatches = null; + const pending = valueBatches; + valueBatches = null; + // Close the value being normalized, then the source. If closing the + // value fails, the source is still closed and the error kept. + let closed; + if (pending === null) { + closed = iterator.return(); + } else { + closed = PromisePrototypeThen( + pending.return(), + () => iterator.return(), + (error) => PromisePrototypeThen(closeQuietly(iterator), () => { + throw error; + })); + } + return PromisePrototypeThen(closed, () => finish(result), fail); + } + + function doThrow(error) { + if (state !== kActive) { + state = kDone; + settled(); + return PromiseReject(error); + } + state = kDone; + boundedBatches = null; + const pending = valueBatches; + valueBatches = null; + const closed = pending === null ? closeQuietly(iterator) : + PromisePrototypeThen(closeQuietly(pending), () => closeQuietly(iterator)); + return PromisePrototypeThen(closed, () => fail(error)); + } + + return ObjectSetPrototypeOf({ + next() { return run(doNext); }, + return(value) { return run(doReturn, value); }, + throw(error) { return run(doThrow, error); }, + }, null); +} + +/** + * Normalize an async streamable source, yielding batches of Uint8Array. + * @param {AsyncIterable|Iterable} source + * @param {object} context + * @returns {object} An async iterator. + */ +function normalizeAsyncSource(source, context) { + // Prefer async iteration if available. + if (isAsyncIterable(source)) { + return createAsyncSourceNormalizer(source, context); + } + return createSyncSourceNormalizer(source, context); +} + +// A value of a sync source that is not a Uint8Array or a Uint8Array[] +// batch, yielded by readSyncSource() to be normalized asynchronously. +function SyncSourceValue(value) { + this.value = value; +} +SyncSourceValue.prototype = ObjectFreeze({ __proto__: null }); + +/** + * Read a sync iterable source for from(): yields Uint8Array[] batches, + * collecting single Uint8Arrays into batches of up to FROM_BATCH_SIZE + * chunks, and a SyncSourceValue for any other value, after the chunks + * collected before it. + * @param {Iterable} source + * @param {object} context + * @yields {Uint8Array[]|SyncSourceValue} + */ +function* readSyncSource(source, context) { + throwIfNormalizationCancelled(context); + if (!isSyncIterable(source)) { + throw new ERR_INVALID_ARG_TYPE( + 'source', ['Iterable', 'AsyncIterable'], source); + } + let batch = []; + for (const value of source) { + throwIfNormalizationCancelled(context); + // Fast path 1: value is already a Uint8Array[] batch + if (isUint8ArrayBatch(value)) { + // Flush any accumulated batch first if (batch.length > 0) { yield batch; batch = []; } - let asyncBatch = []; - for await (const chunk of normalizeAsyncValue(value, false, context)) { - ArrayPrototypePush(asyncBatch, chunk); - if (asyncBatch.length === FROM_BATCH_SIZE) { - yield asyncBatch; - asyncBatch = []; - } + if (value.length <= FROM_BATCH_SIZE) { + if (value.length !== 0) yield value; + } else { + yield* yieldBoundedBatch(value); } - if (asyncBatch.length > 0) { - yield asyncBatch; + continue; + } + // Fast path 2: value is a single Uint8Array (very common) + if (isUint8Array(value)) { + batch[batch.length] = value; + if (batch.length === FROM_BATCH_SIZE) { + yield batch; + batch = []; } + continue; } - - // Yield any remaining batched values + // Slow path: flush, then have the value normalized if (batch.length > 0) { yield batch; + batch = []; } - return; + yield new SyncSourceValue(value); + } + // Yield any remaining batched values + if (batch.length > 0) { + yield batch; } +} - throw new ERR_INVALID_ARG_TYPE( - 'source', - ['Iterable', 'AsyncIterable'], - source, - ); +/** + * Iterator normalizing a sync iterable source for from() into batches of + * Uint8Array, without an async generator layer for every batch (see + * createAsyncSourceNormalizer()). + * + * The source is read by the sync generator readSyncSource(), so for...of + * reads and closes it, and this iterator only adds the asynchronous + * normalization of values that need it. Operations on the generator mirror + * those on the async generator this replaces: return() and throw() are + * passed to it, after closing the value being normalized, and an error + * normalizing a value is thrown into it, so that for...of closes the source + * as for an error in the loop body. + * @param {Iterable} source + * @param {object} context + * @returns {object} An object with next(), return() and throw(). + */ +function createSyncSourceNormalizer(source, context) { + const reader = readSyncSource(source, context); + let done = false; + // A normalizeAsyncSourceValue() generator for the value being normalized. + let valueBatches = null; + const { run, settled, release, idle } = createOperationQueue(); + + // Results are often produced synchronously. An async generator stays busy + // until the tick after a yield or return (both await their operand), so + // that a next(), return() or throw() made synchronously after this one is + // queued behind it; do the same. + function finish(result) { + PromisePrototypeThen(kResolvedPromise, release); + return result; + } + + function fail(error) { + done = true; + settled(); + throw error; + } + + // Throw `error` into the reader, closing the source as for...of does when + // its body throws: errors from closing it are ignored. + function throwIntoReader(error) { + try { + reader.throw(error); + } catch { + // The reader rethrows `error`. + } + } + + function onValueResult(result) { + if (result.done) { + valueBatches = null; + return produce(); + } + return finish(new IterResult(false, result.value)); + } + + function onValueError(error) { + valueBatches = null; + throwIntoReader(error); + return fail(error); + } + + // Produce the next result: an IterResult, or a promise for one when a + // value is normalized asynchronously. Throws, after settling, on error. + function produce() { + if (valueBatches !== null) { + return PromisePrototypeThen( + valueBatches.next(), onValueResult, onValueError); + } + let result; + try { + result = reader.next(); + } catch (error) { + return fail(error); + } + if (result.done) { + done = true; + return finish(new IterResult(true, undefined)); + } + const value = result.value; + if (ArrayIsArray(value)) return finish(new IterResult(false, value)); + valueBatches = normalizeAsyncSourceValue(value.value, context, false); + return PromisePrototypeThen( + valueBatches.next(), onValueResult, onValueError); + } + + function doNext() { + if (done) return PromiseResolve(finish(new IterResult(true, undefined))); + try { + return PromiseResolve(produce()); + } catch (error) { + return PromiseReject(error); + } + } + + function doReturn(value) { + const result = new IterResult(true, value); + if (done) return PromiseResolve(finish(result)); + done = true; + const pending = valueBatches; + valueBatches = null; + if (pending === null) { + try { + reader.return(); + } catch (error) { + settled(); + return PromiseReject(error); + } + return PromiseResolve(finish(result)); + } + // Close the value being normalized, then the source. If closing the + // value fails, the source is still closed and the error kept. + return PromisePrototypeThen(pending.return(), () => { + try { + reader.return(); + } catch (error) { + return fail(error); + } + return finish(result); + }, (error) => { + throwIntoReader(error); + return fail(error); + }); + } + + function doThrow(error) { + if (done) { + settled(); + return PromiseReject(error); + } + done = true; + const pending = valueBatches; + valueBatches = null; + if (pending === null) { + throwIntoReader(error); + settled(); + return PromiseReject(error); + } + return PromisePrototypeThen( + PromisePrototypeThen(pending.return(), undefined, () => {}), + () => { + throwIntoReader(error); + return fail(error); + }); + } + + // Read the next batch like next() but synchronously, when it is read + // synchronously from the source and no operation is running or queued: + // returns the batch, or null when done. Returns undefined, having started + // nothing but the normalization of a value, when next() must be used. + // Throws on error, like next() rejects. + function nextSyncBatch() { + if (!idle() || valueBatches !== null) return undefined; + if (done) return null; + let result; + try { + result = reader.next(); + } catch (error) { + done = true; + throw error; + } + if (result.done) { + done = true; + return null; + } + const value = result.value; + if (ArrayIsArray(value)) return value; + valueBatches = normalizeAsyncSourceValue(value.value, context, false); + return undefined; + } + + return ObjectSetPrototypeOf({ + next() { return run(doNext); }, + return(value) { return run(doReturn, value); }, + throw(error) { return run(doThrow, error); }, + [kNextSyncBatch]: nextSyncBatch, + }, null); } async function* normalizeAsyncStreamableResult(result, context) { @@ -709,7 +1202,7 @@ function fromSync(input) { if (!isIterable && isAsyncIterable(input)) { throw new ERR_INVALID_ARG_TYPE( 'input', - 'a synchronous input (not AsyncIterable)', + 'a synchronous input, not an async iterable', input, ); } @@ -719,7 +1212,7 @@ function fromSync(input) { typeof input.then === 'function') { throw new ERR_INVALID_ARG_TYPE( 'input', - 'a synchronous input (not Promise)', + 'a synchronous input, not a promise', input, ); } @@ -850,6 +1343,7 @@ module.exports = { isPrimitiveChunk, isSyncIterable, isUint8ArrayBatch, + kNextSyncBatch, normalizeAsyncSource, normalizeAsyncValue, normalizeSyncSource, diff --git a/lib/internal/streams/iter/pull.js b/lib/internal/streams/iter/pull.js index 70354edb10b3..1f0b36596939 100644 --- a/lib/internal/streams/iter/pull.js +++ b/lib/internal/streams/iter/pull.js @@ -11,10 +11,16 @@ const { ArrayPrototypePush, ArrayPrototypeSlice, FunctionPrototypeCall, + ObjectDefineProperty, + ObjectFreeze, + ObjectSetPrototypeOf, PromisePrototypeThen, + PromiseReject, PromiseResolve, + Symbol, SymbolAsyncIterator, SymbolIterator, + TypedArrayPrototypeGetByteLength, Uint8Array, } = primordials; @@ -22,6 +28,7 @@ const { codes: { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, + ERR_INVALID_RETURN_VALUE, ERR_INVALID_STATE, ERR_OUT_OF_RANGE, }, @@ -45,12 +52,24 @@ const { isSyncIterable, isAsyncIterable, isUint8ArrayBatch, + kNextSyncBatch, } = require('internal/streams/iter/from'); const { + IterResult, + kActive, + kDone, + kNullOnceOption, + kResolvedPromise, + kStart, + callWithByteView, + checkFixedBatchChunk, createBatchEntry, + createOperationQueue, + fixedBatchToEntry, isTransformObject, parsePullArgs, + snapshotFixedBatch, snapshotTransform, toUint8Array, validateBatchEntry, @@ -249,7 +268,7 @@ function* processTransformResultSync(result) { const batch = []; for (const item of result) { for (const chunk of flattenTransformYieldSync(item)) { - ArrayPrototypePush(batch, chunk); + batch[batch.length] = chunk; } } if (batch.length > 0) { @@ -275,28 +294,28 @@ function appendTransformResultSync(target, result) { } if (isUint8ArrayBatch(result)) { if (result.length > 0) { - ArrayPrototypePush(target, result); + target[target.length] = result; } return; } if (isUint8Array(result)) { - ArrayPrototypePush(target, [result]); + target[target.length] = [result]; return; } if (typeof result === 'string') { - ArrayPrototypePush(target, [toUint8Array(result)]); + target[target.length] = [toUint8Array(result)]; return; } if (isAnyArrayBuffer(result)) { - ArrayPrototypePush(target, [new Uint8Array(result)]); + target[target.length] = [new Uint8Array(result)]; return; } if (ArrayBufferIsView(result)) { - ArrayPrototypePush(target, [arrayBufferViewToUint8Array(result)]); + target[target.length] = [arrayBufferViewToUint8Array(result)]; return; } for (const batch of processTransformResultSync(result)) { - ArrayPrototypePush(target, batch); + target[target.length] = batch; } } @@ -345,11 +364,11 @@ async function* processTransformResultAsync(result) { const batch = []; for await (const item of result) { if (isUint8Array(item)) { - ArrayPrototypePush(batch, item); + batch[batch.length] = item; continue; } for await (const chunk of flattenTransformYieldAsync(item)) { - ArrayPrototypePush(batch, chunk); + batch[batch.length] = chunk; } } if (batch.length > 0) { @@ -362,13 +381,13 @@ async function* processTransformResultAsync(result) { const batch = []; for (const item of result) { if (isUint8Array(item)) { - ArrayPrototypePush(batch, item); + batch[batch.length] = item; continue; } // Note: This iteration is synchronous, since async iterables // may not be nested within sync iterables. for (const chunk of flattenTransformYieldSync(item)) { - ArrayPrototypePush(batch, chunk); + batch[batch.length] = chunk; } } if (batch.length > 0) { @@ -395,24 +414,24 @@ function appendTransformResultAsync(target, result) { } if (isUint8ArrayBatch(result)) { if (result.length > 0) { - ArrayPrototypePush(target, result); + target[target.length] = result; } return; } if (isUint8Array(result)) { - ArrayPrototypePush(target, [result]); + target[target.length] = [result]; return; } if (typeof result === 'string') { - ArrayPrototypePush(target, [toUint8Array(result)]); + target[target.length] = [toUint8Array(result)]; return; } if (isAnyArrayBuffer(result)) { - ArrayPrototypePush(target, [new Uint8Array(result)]); + target[target.length] = [new Uint8Array(result)]; return; } if (ArrayBufferIsView(result)) { - ArrayPrototypePush(target, [arrayBufferViewToUint8Array(result)]); + target[target.length] = [arrayBufferViewToUint8Array(result)]; return; } return appendTransformResultAsyncSlow(target, result); @@ -420,7 +439,7 @@ function appendTransformResultAsync(target, result) { async function appendTransformResultAsyncSlow(target, result) { for await (const batch of processTransformResultAsync(result)) { - ArrayPrototypePush(target, batch); + target[target.length] = batch; } } @@ -518,7 +537,7 @@ function* applyStatefulSyncTransform(source, transform, receiver) { if (item === null) continue; const batch = []; for (const chunk of flattenTransformYieldSync(item)) { - ArrayPrototypePush(batch, chunk); + batch[batch.length] = chunk; } if (batch.length > 0) { yield batch; @@ -561,86 +580,362 @@ function* createSyncPipeline(source, transforms) { // Async Pipeline Implementation // ============================================================================= +// The options object passed to async transforms. Stateless transforms get a +// new one for every call, so it is constructed rather than created as a +// `{ __proto__: null, signal }` literal (a dictionary-mode object). The +// prototype is frozen and has no %Object.prototype% in its chain, and the +// non-enumerable `constructor` lets util.inspect() print the options as +// `TransformOptions { signal }`. +function TransformOptions(signal) { + this.signal = signal; +} +TransformOptions.prototype = ObjectFreeze(ObjectDefineProperty( + { __proto__: null }, 'constructor', + { __proto__: null, value: TransformOptions })); + +/** + * Close an async iterator as for await does: for a throw completion + * (`quiet`), wait for it but ignore errors; otherwise reject on errors and + * on a result that is not an object. + * @param {object} iterator + * @param {boolean} quiet + * @returns {Promise} + */ +function closeAsyncIterator(iterator, quiet) { + let promise; + try { + const returnMethod = iterator.return; + if (returnMethod === undefined || returnMethod === null) { + return kResolvedPromise; + } + promise = PromiseResolve(FunctionPrototypeCall(returnMethod, iterator)); + } catch (error) { + return quiet ? kResolvedPromise : PromiseReject(error); + } + if (quiet) return PromisePrototypeThen(promise, undefined, () => {}); + return PromisePrototypeThen(promise, (result) => { + if ((typeof result !== 'object' && typeof result !== 'function') || + result === null) { + throw new ERR_INVALID_RETURN_VALUE( + 'an object', 'iterator.return()', result); + } + }); +} + /** - * Apply a single stateless async transform to a source. + * Normalize the output of a fused run of stateless transforms for a batch. * @yields {Uint8Array[]} */ +async function* yieldFusedStatelessOutput(current) { + if (isUint8ArrayBatch(current)) { + if (current.length > 0) yield current; + } else if (isUint8Array(current)) { + yield [current]; + } else if (typeof current === 'string') { + yield [toUint8Array(current)]; + } else if (isAnyArrayBuffer(current)) { + yield [new Uint8Array(current)]; + } else if (ArrayBufferIsView(current)) { + yield [arrayBufferViewToUint8Array(current)]; + } else { + yield* processTransformResultAsync(current); + } +} + +/** + * Apply a fused run of stateless transforms to a batch from transform + * `index` on, given the result of that transform, when the result has to be + * waited for or normalized asynchronously. + * @param {Array} run + * @param {number} index + * @param {any} result - The result of `run[index]` + * @param {AbortSignal} signal + * @yields {Uint8Array[]} + */ +async function* continueFusedStatelessBatch(run, index, result, signal) { + let current; + for (let i = index; i < run.length; i++) { + if (i !== index) result = run[i](current, new TransformOptions(signal)); + if (isPromise(result)) result = await result; + if (result === null) return; + if (i === run.length - 1) { + current = result; + break; + } + current = normalizeTransformResultFast(result); + if (current === undefined) { + const normalized = []; + const pendingResult = appendTransformResultAsync(normalized, result); + if (pendingResult !== undefined) await pendingResult; + current = normalized.length === 0 ? null : normalized[0]; + } + if (current === null) return; + } + yield* yieldFusedStatelessOutput(current); +} + +/** + * Flush a fused run of stateless transforms once the source has ended: + * flush each transform after all upstream data, including data emitted by + * earlier flushes, has been processed by that transform. + * @param {Array} run + * @param {AbortSignal} signal + * @yields {Uint8Array[]} + */ +async function* flushFusedStatelessAsyncTransforms(run, signal) { + let pending = []; + for (let i = 0; i < run.length; i++) { + const next = []; + for (let j = 0; j < pending.length; j++) { + const pendingResult = appendTransformResultAsync( + next, + run[i](pending[j], new TransformOptions(signal))); + if (pendingResult !== undefined) { + await pendingResult; + } + } + const flushResult = appendTransformResultAsync( + next, + run[i](null, new TransformOptions(signal))); + if (flushResult !== undefined) { + await flushResult; + } + pending = next; + } + for (let i = 0; i < pending.length; i++) { + yield pending[i]; + } +} + +// Returned by applyBatch() below when a batch is processed by a delegate. +const kDelegated = Symbol('kDelegated'); + /** * Apply a fused run of stateless async transforms to a source. * All transforms in the run are applied in a tight synchronous loop per batch, * avoiding the overhead of N async generator ticks for N transforms. * + * This is what an async generator looping over the source with for await + * would do, written out by hand, as an async generator layer costs several + * promises and an async frame for every batch: when the transforms return + * batches or chunks synchronously, a batch takes a single promise. Results + * that have to be waited for or normalized asynchronously, and the flush + * once the source has ended, are handled by async generators. As with an + * async generator looping over the source: + * - the source is opened by the first next(), and calls made while one is in + * progress are queued; + * - an error from the source ends the iteration without closing it, and an + * error from a transform closes it, ignoring errors from closing it; + * - return() and throw() are passed to the async generator handling a batch, + * if any, and close the source unless it has ended; return() propagates + * errors from closing it, throw() ignores them. + * * INVARIANT: This function accepts a signal, NOT a pre-built options object. - * A fresh { __proto__: null, signal } options object is created for each + * A fresh TransformOptions object is created for each * transform invocation to prevent cross-transform mutation. * @param {AsyncIterable} source * @param {Array} run - Array of stateless transform functions * @param {AbortSignal} signal - The pipeline's abort signal - * @yields {Uint8Array[]} + * @returns {AsyncIterator} */ -async function* applyFusedStatelessAsyncTransforms(source, run, signal) { - for await (const chunks of source) { +function applyFusedStatelessAsyncTransforms(source, run, signal) { + let state = kStart; + let iterator; + let nextMethod; + // Whether the source has ended, and the run is being flushed. + let sourceDone = false; + // An async generator yielding the output of a batch, or of the flush. + let delegate = null; + const operations = createOperationQueue(); + + function finish(result) { + operations.settled(); + return result; + } + + function fail(error) { + state = kDone; + operations.settled(); + throw error; + } + + // Like for await when its body throws: close the source, keep the error. + function onBodyError(error) { + delegate = null; + state = kDone; + return PromisePrototypeThen( + closeAsyncIterator(iterator, true), () => fail(error)); + } + + function onDelegateError(error) { + if (!sourceDone) return onBodyError(error); + delegate = null; + return fail(error); + } + + function onDelegateResult(result) { + if (!result.done) return finish(new IterResult(false, result.value)); + delegate = null; + if (!sourceDone) return pullSource(); + state = kDone; + return finish(new IterResult(true, undefined)); + } + + function pullDelegate() { + return PromisePrototypeThen( + delegate.next(), onDelegateResult, onDelegateError); + } + + // Apply the run to a batch synchronously: returns the output batch, null + // if there is none, or kDelegated if `delegate` is to produce it. + function applyBatch(chunks) { let current = chunks; for (let i = 0; i < run.length; i++) { - let result = run[i](current, { __proto__: null, signal }); - if (isPromise(result)) result = await result; - if (result === null) { - current = null; - break; + const result = run[i](current, new TransformOptions(signal)); + if (isPromise(result)) { + delegate = continueFusedStatelessBatch(run, i, result, signal); + return kDelegated; } + if (result === null) return null; if (i === run.length - 1) { current = result; - continue; + break; } current = normalizeTransformResultFast(result); if (current === undefined) { - const normalized = []; - const pendingResult = appendTransformResultAsync(normalized, result); - if (pendingResult !== undefined) await pendingResult; - current = normalized.length === 0 ? null : normalized[0]; + delegate = continueFusedStatelessBatch(run, i, result, signal); + return kDelegated; } - if (current === null) break; + if (current === null) return null; } - if (current === null) continue; - // Normalize the final output if (isUint8ArrayBatch(current)) { - if (current.length > 0) yield current; - } else if (isUint8Array(current)) { - yield [current]; - } else if (typeof current === 'string') { - yield [toUint8Array(current)]; - } else if (isAnyArrayBuffer(current)) { - yield [new Uint8Array(current)]; - } else if (ArrayBufferIsView(current)) { - yield [arrayBufferViewToUint8Array(current)]; - } else { - yield* processTransformResultAsync(current); + return current.length > 0 ? current : null; } + if (isUint8Array(current)) return [current]; + if (typeof current === 'string') return [toUint8Array(current)]; + if (isAnyArrayBuffer(current)) return [new Uint8Array(current)]; + if (ArrayBufferIsView(current)) { + return [arrayBufferViewToUint8Array(current)]; + } + delegate = processTransformResultAsync(current); + return kDelegated; } - // Flush each transform after all upstream data, including data emitted by - // earlier flushes, has been processed by that transform. - let pending = []; - for (let i = 0; i < run.length; i++) { - const next = []; - for (let j = 0; j < pending.length; j++) { - const pendingResult = appendTransformResultAsync( - next, - run[i](pending[j], { __proto__: null, signal })); - if (pendingResult !== undefined) { - await pendingResult; + + function onSourceResult(result) { + let value; + try { + if ((typeof result !== 'object' && typeof result !== 'function') || + result === null) { + throw new ERR_INVALID_RETURN_VALUE( + 'an object', 'iterator.next()', result); } + if (result.done) { + sourceDone = true; + delegate = flushFusedStatelessAsyncTransforms(run, signal); + return pullDelegate(); + } + value = result.value; + } catch (error) { + return fail(error); } - const flushResult = appendTransformResultAsync( - next, - run[i](null, { __proto__: null, signal })); - if (flushResult !== undefined) { - await flushResult; + let batch; + try { + batch = applyBatch(value); + } catch (error) { + return onBodyError(error); } - pending = next; + if (batch === kDelegated) return pullDelegate(); + if (batch === null) return pullSource(); + return finish(new IterResult(false, batch)); } - for (let i = 0; i < pending.length; i++) { - yield pending[i]; + + function pullSource() { + let promise; + try { + promise = PromiseResolve(FunctionPrototypeCall(nextMethod, iterator)); + } catch (error) { + return fail(error); + } + return PromisePrototypeThen(promise, onSourceResult, fail); + } + + function doNext() { + if (state === kDone) { + return PromiseResolve(finish(new IterResult(true, undefined))); + } + if (state === kStart) { + try { + iterator = source[SymbolAsyncIterator](); + nextMethod = iterator.next; + } catch (error) { + state = kDone; + operations.settled(); + return PromiseReject(error); + } + state = kActive; + } + try { + return PromiseResolve(delegate !== null ? pullDelegate() : pullSource()); + } catch (error) { + return PromiseReject(error); + } + } + + function doReturn(value) { + const result = new IterResult(true, value); + if (state !== kActive) { + state = kDone; + return PromiseResolve(finish(result)); + } + state = kDone; + const pending = delegate; + delegate = null; + const open = sourceDone ? null : iterator; + let closed; + if (pending === null) { + closed = open === null ? kResolvedPromise : + closeAsyncIterator(open, false); + } else { + // Close the delegate, then the source. If closing the delegate fails, + // the source is still closed and the error kept. + closed = PromisePrototypeThen( + closeAsyncIterator(pending, false), + () => (open === null ? undefined : closeAsyncIterator(open, false)), + (error) => { + if (open === null) throw error; + return PromisePrototypeThen(closeAsyncIterator(open, true), () => { + throw error; + }); + }); + } + return PromisePrototypeThen(closed, () => finish(result), fail); + } + + function doThrow(error) { + if (state !== kActive) { + state = kDone; + operations.settled(); + return PromiseReject(error); + } + state = kDone; + const pending = delegate; + delegate = null; + // The delegate rethrows `error` (as yield* would see it do). + let closed = pending === null ? kResolvedPromise : + PromisePrototypeThen(pending.throw(error), undefined, () => {}); + if (!sourceDone) { + closed = PromisePrototypeThen( + closed, () => closeAsyncIterator(iterator, true)); + } + return PromisePrototypeThen(closed, () => fail(error)); } + + return ObjectSetPrototypeOf({ + next() { return operations.run(doNext); }, + return(value) { return operations.run(doReturn, value); }, + throw(error) { return operations.run(doThrow, error); }, + [SymbolAsyncIterator]() { return this; }, + }, null); } /** @@ -677,7 +972,7 @@ async function* applyStatefulAsyncTransform( // Slow path: flatten arbitrary transform yield const batch = []; for await (const chunk of flattenTransformYieldAsync(item)) { - ArrayPrototypePush(batch, chunk); + batch[batch.length] = chunk; } if (batch.length > 0) { yield batch; @@ -708,93 +1003,155 @@ async function* applyValidatedStatefulAsyncTransform( /** * Create an async pipeline from source through transforms. - * @yields {Uint8Array[]} + * @param {AsyncIterable} source + * @param {Array} transforms + * @param {AbortSignal} [signal] + * @returns {AsyncIterator} */ -async function* createAsyncPipeline(source, transforms, signal) { - // Check for abort - signal?.throwIfAborted(); - - // Fast path: no transforms, just yield normalized source directly +function createAsyncPipeline(source, transforms, signal) { if (transforms.length === 0) { - yield* yieldAbortable(source, signal); - return; + return createAsyncPipelineWithoutTransforms(source, signal); } + return createAsyncTransformPipeline(source, transforms, signal); +} - const normalized = yieldAbortable(source, signal); +/** + * The pipeline without transforms: what an async generator doing + * `signal?.throwIfAborted(); yield* yieldAbortable(source, signal);` would + * do. With a signal, it is written out by hand to avoid an async generator + * layer for every batch: the signal is checked by the first next(), then + * every call is passed to the iterator of yieldAbortable(), which queues + * them and completes as the generator would. + * @param {AsyncIterable} source + * @param {AbortSignal} [signal] + * @returns {AsyncIterator} + */ +function createAsyncPipelineWithoutTransforms(source, signal) { + if (signal === undefined) return yieldFrom(source); + let state = kStart; + let iterator; + return ObjectSetPrototypeOf({ + next() { + if (state === kStart) { + try { + // Check for abort + signal.throwIfAborted(); + iterator = yieldAbortable(source, signal)[SymbolAsyncIterator](); + } catch (error) { + state = kDone; + return PromiseReject(error); + } + state = kActive; + } else if (state === kDone) { + return PromiseResolve(new IterResult(true, undefined)); + } + return iterator.next(); + }, + return(value) { + if (state !== kActive) { + state = kDone; + return PromiseResolve(new IterResult(true, value)); + } + return iterator.return(value); + }, + throw(error) { + if (state !== kActive) { + state = kDone; + return PromiseReject(error); + } + return iterator.throw(error); + }, + [SymbolAsyncIterator]() { + return this; + }, + }, null); +} - // Create internal controller for transform cancellation. - // Note: if signal was already aborted, we threw above - no need to check here. - const controller = new AbortController(); - let abortHandler; - if (signal) { - abortHandler = () => { - abortSignal(controller.signal, signal.reason); - }; - signal.addEventListener('abort', abortHandler, { __proto__: null, once: true }); - } +async function* yieldFrom(source) { + yield* source; +} - let completed = false; - try { - // Apply transforms - fuse consecutive stateless transforms into a single - // generator layer to avoid unnecessary async generator ticks. - // - // INVARIANT: Each transform invocation MUST receive its own fresh options - // object ({ __proto__: null, signal }). Transforms may mutate the options - // object, so sharing a single object across invocations would allow one - // transform to corrupt the options seen by another. The signal is shared - // across calls (mutations to it are acceptable), but the containing options - // object must be unique per call. This is enforced inside - // applyFusedStatelessAsyncTransforms and applyStatefulAsyncTransform, which - // accept the signal directly and create the options object per invocation. - // DO NOT pass a pre-built options object. - let current = normalized; - const transformSignal = controller.signal; - let statelessRun = []; - - for (let i = 0; i < transforms.length; i++) { - const transform = transforms[i]; - if (isTransformObject(transform)) { - // Flush any accumulated stateless run before the stateful transform - if (statelessRun.length > 0) { - current = applyFusedStatelessAsyncTransforms(current, statelessRun, - transformSignal); - statelessRun = []; - } - const opts = { __proto__: null, signal: transformSignal }; - if (transform[kValidatedTransform]) { - current = applyValidatedStatefulAsyncTransform( - current, transform.transform, transform.receiver, opts); - } else { - current = applyStatefulAsyncTransform( - current, transform.transform, transform.receiver, opts); - } +/** + * Build the chain of transform layers of a pipeline. + * @param {AsyncIterable} normalized + * @param {Array} transforms + * @param {AbortSignal} transformSignal + * @returns {AsyncIterable} + */ +function createAsyncTransformLayers(normalized, transforms, transformSignal) { + // Apply transforms - fuse consecutive stateless transforms into a single + // layer to avoid unnecessary async ticks. + // + // INVARIANT: Each transform invocation MUST receive its own fresh options + // object (new TransformOptions(signal)). Transforms may mutate the options + // object, so sharing a single object across invocations would allow one + // transform to corrupt the options seen by another. The signal is shared + // across calls (mutations to it are acceptable), but the containing options + // object must be unique per call. This is enforced inside + // applyFusedStatelessAsyncTransforms and applyStatefulAsyncTransform, which + // accept the signal directly and create the options object per invocation. + // DO NOT pass a pre-built options object. + let current = normalized; + let statelessRun = []; + + for (let i = 0; i < transforms.length; i++) { + const transform = transforms[i]; + if (isTransformObject(transform)) { + // Flush any accumulated stateless run before the stateful transform + if (statelessRun.length > 0) { + current = applyFusedStatelessAsyncTransforms(current, statelessRun, + transformSignal); + statelessRun = []; + } + const opts = new TransformOptions(transformSignal); + if (transform[kValidatedTransform]) { + current = applyValidatedStatefulAsyncTransform( + current, transform.transform, transform.receiver, opts); } else { - ArrayPrototypePush(statelessRun, transform); + current = applyStatefulAsyncTransform( + current, transform.transform, transform.receiver, opts); } + } else { + ArrayPrototypePush(statelessRun, transform); } - // Flush remaining stateless run - if (statelessRun.length > 0) { - current = applyFusedStatelessAsyncTransforms(current, statelessRun, - transformSignal); - } + } + // Flush remaining stateless run + if (statelessRun.length > 0) { + current = applyFusedStatelessAsyncTransforms(current, statelessRun, + transformSignal); + } + return current; +} - for await (const batch of current) { - controller.signal.throwIfAborted(); - yield batch; - } - // A transform can abort while completing without producing a final batch, - // for example when an async flush resolves to null. In that case the loop - // body has no opportunity to observe the abort. - controller.signal.throwIfAborted(); - completed = true; - } catch (error) { - if (!controller.signal.aborted) { - abortSignal(controller.signal, error); - } - throw error; - } finally { +/** + * The pipeline through one or more transforms: an async iterator doing what + * an async generator would, written out by hand to avoid an async generator + * layer for every batch (see applyFusedStatelessAsyncTransforms()). + * + * When started by the first next(), it checks `signal`, then creates the + * controller whose signal the transforms get, aborted when `signal` aborts. + * Each batch, and the end, is passed on only if the transforms' signal has + * not been aborted. If the pipeline fails, the transforms' signal is aborted + * with the error; if it is stopped early by return() or throw(), the + * transforms are closed and their signal is aborted. + * @param {AsyncIterable} source + * @param {Array} transforms + * @param {AbortSignal} [signal] + * @returns {AsyncIterator} + */ +function createAsyncTransformPipeline(source, transforms, signal) { + let state = kStart; + let controller; + let abortHandler; + let completed = false; + let iterator; + let nextMethod; + const operations = createOperationQueue(); + + // What an async generator would do in its `finally` block. + function cleanup() { if (!completed && !controller.signal.aborted) { - // Consumer stopped early or generator return() was called. + // Consumer stopped early or return() was called. // If a transform listener throws here, let it propagate. controller.abort(lazyDOMException('Aborted', 'AbortError')); } @@ -803,6 +1160,150 @@ async function* createAsyncPipeline(source, transforms, signal) { signal.removeEventListener('abort', abortHandler); } } + + function finish(result) { + operations.settled(); + return result; + } + + function complete(result) { + state = kDone; + try { + cleanup(); + } finally { + operations.settled(); + } + return result; + } + + // What an async generator would do in its `catch` block, then `finally`. + function fail(error) { + state = kDone; + try { + try { + if (!controller.signal.aborted) { + abortSignal(controller.signal, error); + } + } finally { + cleanup(); + } + } finally { + operations.settled(); + } + throw error; + } + + function onResult(result) { + let value; + try { + if ((typeof result !== 'object' && typeof result !== 'function') || + result === null) { + throw new ERR_INVALID_RETURN_VALUE( + 'an object', 'iterator.next()', result); + } + if (result.done) { + // A transform can abort while completing without producing a final + // batch, for example when an async flush resolves to null. In that + // case there is no batch with which to observe the abort. + controller.signal.throwIfAborted(); + completed = true; + return complete(new IterResult(true, undefined)); + } + value = result.value; + } catch (error) { + return fail(error); + } + try { + controller.signal.throwIfAborted(); + } catch (error) { + // Like for await when its body throws: close the transforms first. + return PromisePrototypeThen( + closeAsyncIterator(iterator, true), () => fail(error)); + } + return finish(new IterResult(false, value)); + } + + function pullTransforms() { + let promise; + try { + promise = PromiseResolve(FunctionPrototypeCall(nextMethod, iterator)); + } catch (error) { + return fail(error); + } + return PromisePrototypeThen(promise, onResult, fail); + } + + function doNext() { + if (state === kDone) { + return PromiseResolve(finish(new IterResult(true, undefined))); + } + if (state === kStart) { + try { + // Check for abort + signal?.throwIfAborted(); + } catch (error) { + state = kDone; + operations.settled(); + return PromiseReject(error); + } + state = kActive; + const normalized = yieldAbortable(source, signal); + // Create internal controller for transform cancellation. + controller = new AbortController(); + if (signal) { + abortHandler = () => { + abortSignal(controller.signal, signal.reason); + }; + signal.addEventListener('abort', abortHandler, kNullOnceOption); + } + try { + const current = createAsyncTransformLayers( + normalized, transforms, controller.signal); + iterator = current[SymbolAsyncIterator](); + nextMethod = iterator.next; + } catch (error) { + try { + fail(error); + } catch (failure) { + return PromiseReject(failure); + } + } + } + try { + return PromiseResolve(pullTransforms()); + } catch (error) { + return PromiseReject(error); + } + } + + function doReturn(value) { + const result = new IterResult(true, value); + if (state !== kActive) { + state = kDone; + return PromiseResolve(finish(result)); + } + state = kDone; + return PromisePrototypeThen( + closeAsyncIterator(iterator, false), () => complete(result), fail); + } + + function doThrow(error) { + if (state !== kActive) { + state = kDone; + operations.settled(); + return PromiseReject(error); + } + state = kDone; + return PromisePrototypeThen( + closeAsyncIterator(iterator, true), () => fail(error)); + } + + return ObjectSetPrototypeOf({ + next() { return operations.run(doNext); }, + return(value) { return operations.run(doReturn, value); }, + throw(error) { return operations.run(doThrow, error); }, + [SymbolAsyncIterator]() { return this; }, + }, null); } // ============================================================================= @@ -850,41 +1351,80 @@ function pull(source, ...args) { }); const { signal } = options; const normalized = from(source); - signal?.throwIfAborted(); return { __proto__: null, [SymbolAsyncIterator]() { - const controller = new AbortController(); - const iteratorSignal = signal === undefined ? - controller.signal : AbortSignal.any([signal, controller.signal]); - - async function* pipeline() { - yield* createAsyncPipeline(normalized, transforms, iteratorSignal); + if (signal === undefined) { + const controller = new AbortController(); + const iterator = createAsyncPipeline( + normalized, transforms, controller.signal); + return ObjectSetPrototypeOf({ + next(value) { + return iterator.next(value); + }, + return(value) { + controller.abort(lazyDOMException('Aborted', 'AbortError')); + return iterator.return(value); + }, + throw(error) { + abortSignal(controller.signal, error); + return iterator.throw(error); + }, + [SymbolAsyncIterator]() { + return this; + }, + }, null); } - 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) { - abortSignal(controller.signal, error); - return iterator.throw(error); - }, - [SymbolAsyncIterator]() { - return this; - }, - }; + return createAbortablePullIterator(normalized, transforms, signal); }, }; } +// Once `signal` aborts the pipeline, the pull that observed it rejects and +// so does every later pull, with the abort reason. That includes the case of +// an already-aborted signal, where the pipeline is never started. A plain +// async generator would instead complete after throwing, so later pulls +// would report a clean end of the stream. +function createAbortablePullIterator(source, transforms, signal) { + let aborted = signal.aborted; + let controller; + let iterator; + if (!aborted) { + controller = new AbortController(); + const iteratorSignal = AbortSignal.any([signal, controller.signal]); + iterator = createAsyncPipeline(source, transforms, iteratorSignal); + } + + function onRejected(error) { + if (signal.aborted) aborted = true; + throw error; + } + + return ObjectSetPrototypeOf({ + next(value) { + if (aborted) return PromiseReject(signal.reason); + return PromisePrototypeThen(iterator.next(value), undefined, onRejected); + }, + return(value) { + if (aborted) { + return PromiseResolve(new IterResult(true, value)); + } + controller.abort(lazyDOMException('Aborted', 'AbortError')); + return iterator.return(value); + }, + throw(error) { + if (aborted) return PromiseReject(error); + abortSignal(controller.signal, error); + return PromisePrototypeThen(iterator.throw(error), undefined, + onRejected); + }, + [SymbolAsyncIterator]() { + return this; + }, + }, null); +} + // 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) { @@ -930,8 +1470,7 @@ function pullWithConsumerCleanup(source, transforms, signal) { if (signal !== undefined) { abortHandler = () => closeSource('throw', signal.reason); - signal.addEventListener('abort', abortHandler, - { __proto__: null, once: true }); + signal.addEventListener('abort', abortHandler, kNullOnceOption); if (signal.aborted) abortHandler(); } @@ -939,8 +1478,7 @@ function pullWithConsumerCleanup(source, transforms, signal) { __proto__: null, [SymbolAsyncIterator]() { const iterator = pipeline[SymbolAsyncIterator](); - return { - __proto__: null, + return ObjectSetPrototypeOf({ next(value) { return PromisePrototypeThen( iterator.next(value), @@ -964,7 +1502,7 @@ function pullWithConsumerCleanup(source, transforms, signal) { [SymbolAsyncIterator]() { return this; }, - }; + }, null); }, }; } @@ -987,12 +1525,9 @@ function pipeToSync(source, ...args) { context: 'options', }); const hasWritevSync = typeof writer.writevSync === 'function'; + // endSync() is optional: a writer without it is not closed. const endSync = writer.endSync; - - if (!options.preventClose && typeof endSync !== 'function') { - throw new ERR_INVALID_ARG_TYPE( - 'writer.endSync', 'Function', endSync); - } + const hasEndSync = typeof endSync === 'function'; // Normalize source and create pipeline const normalized = fromSync(source); @@ -1001,9 +1536,38 @@ function pipeToSync(source, ...args) { normalized; let totalBytes = 0; + let closedSync = true; try { for (const batch of pipeline) { + // Single chunk, the common case: no batch entry needed. + if (batch.length === 1) { + const chunk = batch[0]; + if (callWithByteView(chunk, writer.writeSync, writer) === false) { + throw new ERR_OUT_OF_RANGE( + 'write', 'within byte budget', 'budget exhausted'); + } + totalBytes += TypedArrayPrototypeGetByteLength(chunk); + continue; + } + if (!hasWritevSync) { + // Chunks written one at a time: snapshot them without an object for + // every chunk when possible. + const fixed = snapshotFixedBatch(batch); + if (fixed !== null) { + for (let i = 0; i < batch.length; i++) { + const chunk = checkFixedBatchChunk(fixed, i); + const accepted = writer.writeSync(chunk); + checkFixedBatchChunk(fixed, i); + if (accepted === false) { + throw new ERR_OUT_OF_RANGE( + 'write', 'within byte budget', 'budget exhausted'); + } + totalBytes += fixed.byteLengths[i]; + } + continue; + } + } const entry = createBatchEntry(batch); if (hasWritevSync && batch.length > 1) { const accepted = writer.writevSync(validateBatchEntry(entry)); @@ -1028,15 +1592,26 @@ function pipeToSync(source, ...args) { } } - if (!options.preventClose) { - if (FunctionPrototypeCall(endSync, writer) < 0) { - throw new ERR_INVALID_STATE( - 'Writer could not be closed synchronously'); - } + if (!options.preventClose && hasEndSync) { + closedSync = FunctionPrototypeCall(endSync, writer) >= 0; } } catch (error) { if (!options.preventFail) { - writer.fail?.(error); + failWriterQuietly(writer, error); + } + throw error; + } + + // endSync() returning -1 only means that the writer cannot close + // synchronously; every chunk was accepted. pipeToSync() never falls back to + // the async end(), so report it. By default the writer is left as it is so + // that the caller can still close it (e.g. with `await writer.end()`); + // `failOnIncompleteClose` fails it instead, for callers that cannot. + if (!closedSync) { + const error = new ERR_INVALID_STATE( + 'Writer could not be closed synchronously'); + if (options.failOnIncompleteClose && !options.preventFail) { + failWriterQuietly(writer, error); } throw error; } @@ -1044,6 +1619,17 @@ function pipeToSync(source, ...args) { return totalBytes; } +// Call writer.fail(error) on a best-effort basis. The error that made the +// pipe fail is what the caller must see; an exception from fail() itself +// must not replace it. +function failWriterQuietly(writer, error) { + try { + writer.fail?.(error); + } catch { + // Ignored, see above. + } +} + /** * Write an async source through transforms to a writer. * @param {AsyncIterable|Iterable} source @@ -1061,7 +1647,7 @@ async function pipeTo(source, ...args) { function failWriter(error) { if (!options.preventFail) { - writer.fail?.(error); + failWriterQuietly(writer, error); } } @@ -1108,7 +1694,65 @@ async function pipeTo(source, ...args) { // Write a batch using try-fallback: sync first, async if needed. // Returns undefined on sync success, or a Promise when async fallback // is required. Callers must check: const p = writeBatch(b); if (p) await p; + // Write a FixedBatch with writeSync(), like the loop at the end of + // writeBatch(), falling back to the async path for the rest of the batch. + function writeFixedBatch(fixed) { + const { chunks } = fixed; + for (let i = 0; i < chunks.length; i++) { + const chunk = checkFixedBatchChunk(fixed, i); + if (!writer.writeSync(chunk)) { + checkFixedBatchChunk(fixed, i); + return writeBatchAsyncFallback(fixedBatchToEntry(fixed), i); + } + checkFixedBatchChunk(fixed, i); + totalBytes += fixed.byteLengths[i]; + } + } + + // Write the batches of a sync source as the for await...of loop below + // does, reading them synchronously when possible: an error reading a + // batch ends the loop, and an error writing one closes the source first, + // ignoring errors closing it. + async function pipeSyncSource(iterator) { + for (;;) { + let batch = iterator[kNextSyncBatch](); + if (batch === undefined) { + const result = await iterator.next(); + if (result.done) return; + batch = result.value; + } else if (batch === null) { + return; + } + try { + const p = writeBatch(batch); + if (p) await p; + } catch (error) { + try { + await iterator.return(); + } catch { + // The error writing the batch is thrown. + } + throw error; + } + } + } + function writeBatch(batch) { + // Single chunk, the common case: check the view around writeSync() + // without allocating a batch entry, and create one only to fall back to + // the async path. + if (batch.length === 1 && hasWriteSync) { + const chunk = batch[0]; + if (callWithByteView(chunk, writer.writeSync, writer)) { + totalBytes += TypedArrayPrototypeGetByteLength(chunk); + return; + } + return writeBatchAsyncFallback(createBatchEntry(batch), 0); + } + if (!hasWritev && hasWriteSync) { + const fixed = snapshotFixedBatch(batch); + if (fixed !== null) return writeFixedBatch(fixed); + } const entry = createBatchEntry(batch); if (hasWritev && batch.length > 1) { if (!hasWritevSync || @@ -1152,6 +1796,8 @@ async function pipeTo(source, ...args) { const p = writeBatch(batch); if (p) await p; } + } else if (normalized[kNextSyncBatch] !== undefined) { + await pipeSyncSource(normalized); } else { for await (const batch of normalized) { const p = writeBatch(batch); diff --git a/lib/internal/streams/iter/push.js b/lib/internal/streams/iter/push.js index 31b437c85b75..a5174ad7e558 100644 --- a/lib/internal/streams/iter/push.js +++ b/lib/internal/streams/iter/push.js @@ -7,6 +7,7 @@ const { ArrayPrototypePush, + ObjectSetPrototypeOf, PromisePrototypeThen, PromiseReject, PromiseResolve, @@ -22,15 +23,17 @@ const { ERR_INVALID_STATE, }, } = require('internal/errors'); -const { - validateInteger, -} = require('internal/validators'); const { drainableProtocol, + kValidatedSource, } = require('internal/streams/iter/types'); const { + IterResult, + PendingRequest, + PendingWrite, + kNullOnceOption, kPushDefaultBudget, kResolvedPromise, createBatchEntry, @@ -40,6 +43,7 @@ const { getWriterSignal, parsePullArgs, validateBatchEntry, + validateBudget, } = require('internal/streams/iter/utils'); const { converters, @@ -70,10 +74,7 @@ function raceEndWithSignal(promise, signal) { } = PromiseWithResolvers(); const onAbort = () => reject(signal.reason); - signal.addEventListener('abort', onAbort, { - __proto__: null, - once: true, - }); + signal.addEventListener('abort', onAbort, kNullOnceOption); PromisePrototypeThen( promise, (value) => { @@ -132,7 +133,7 @@ class PushQueue { backpressure = 'strict', signal, } = options; - validateInteger(budget, 'options.budget', 16384); + validateBudget(budget); this.#budget = budget; this.#backpressure = backpressure; this.#signal = signal; @@ -294,7 +295,7 @@ class PushQueue { */ #createPendingWrite(batch, signal) { const { promise, resolve, reject } = PromiseWithResolvers(); - const entry = { __proto__: null, batch, resolve, reject }; + const entry = new PendingWrite(batch, resolve, reject); this.#pendingWrites.push(entry); if (signal) { @@ -318,7 +319,7 @@ class PushQueue { reject(reason); }; - signal.addEventListener('abort', onAbort, { __proto__: null, once: true }); + signal.addEventListener('abort', onAbort, kNullOnceOption); } return promise; @@ -429,7 +430,7 @@ class PushQueue { */ waitForDrain() { const { promise, resolve, reject } = PromiseWithResolvers(); - ArrayPrototypePush(this.#pendingDrains, { __proto__: null, resolve, reject }); + ArrayPrototypePush(this.#pendingDrains, new PendingRequest(resolve, reject)); return promise; } @@ -439,7 +440,7 @@ class PushQueue { async read() { if (this.#consumerState === 'returned') { - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } if (this.#consumerState === 'thrown') { throw this.#consumerError; @@ -449,17 +450,17 @@ class PushQueue { if (this.#slots.length > 0) { const result = this.#drain(); this.#resolvePendingWrites(); - return { __proto__: null, done: false, value: result }; + return new IterResult(false, result); } // Buffer empty and writer closing = drain complete if (this.#writerState === 'closing') { this.endDrained(); - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } if (this.#writerState === 'closed') { - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } if (this.#writerState === 'errored') { @@ -467,7 +468,7 @@ class PushQueue { } const { promise, resolve, reject } = PromiseWithResolvers(); - this.#pendingReads.push({ __proto__: null, resolve, reject }); + this.#pendingReads.push(new PendingRequest(resolve, reject)); return promise; } @@ -508,7 +509,7 @@ class PushQueue { 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]); + result[result.length] = batch[j]; } } this.#slots.clear(); @@ -541,7 +542,7 @@ class PushQueue { while (this.#pendingReads.length > 0) { if (this.#consumerState === 'returned') { const pending = this.#pendingReads.shift(); - pending.resolve({ __proto__: null, done: true, value: undefined }); + pending.resolve(new IterResult(true, undefined)); } else if (this.#consumerState === 'thrown') { const pending = this.#pendingReads.shift(); pending.reject(this.#consumerError); @@ -550,7 +551,7 @@ class PushQueue { try { const result = this.#drain(); this.#resolvePendingWrites(); - pending.resolve({ __proto__: null, done: false, value: result }); + pending.resolve(new IterResult(false, result)); } catch (error) { pending.reject(error); } @@ -559,10 +560,10 @@ class PushQueue { this.#pendingWrites.length === 0) { this.endDrained(); const pending = this.#pendingReads.shift(); - pending.resolve({ __proto__: null, done: true, value: undefined }); + pending.resolve(new IterResult(true, undefined)); } else if (this.#writerState === 'closed') { const pending = this.#pendingReads.shift(); - pending.resolve({ __proto__: null, done: true, value: undefined }); + pending.resolve(new IterResult(true, undefined)); } else if (this.#writerState === 'errored') { const pending = this.#pendingReads.shift(); pending.reject(this.#writerError); @@ -593,6 +594,7 @@ class PushQueue { } #resolvePendingDrains(canWrite) { + if (this.#pendingDrains.length === 0) return; const drains = this.#pendingDrains; this.#pendingDrains = []; for (let i = 0; i < drains.length; i++) { @@ -601,6 +603,7 @@ class PushQueue { } #rejectPendingDrains(error) { + if (this.#pendingDrains.length === 0) return; const drains = this.#pendingDrains; this.#pendingDrains = []; for (let i = 0; i < drains.length; i++) { @@ -734,21 +737,23 @@ class PushWriter { function createReadable(queue) { return { __proto__: null, + // The batches read are validated already: from() and pipeTo() can read + // them without normalizing them again. + [kValidatedSource]: true, [SymbolAsyncIterator]() { - return { - __proto__: null, + return ObjectSetPrototypeOf({ async next() { return queue.read(); }, - async return() { + async return(value) { queue.consumerReturn(); - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, value); }, async throw(error) { queue.consumerThrow(error); throw error; }, - }; + }, null); }, }; } diff --git a/lib/internal/streams/iter/share.js b/lib/internal/streams/iter/share.js index b53da4608b23..937528b3929c 100644 --- a/lib/internal/streams/iter/share.js +++ b/lib/internal/streams/iter/share.js @@ -38,13 +38,16 @@ const { } = require('internal/streams/iter/pull'); const { + IterResult, kMultiConsumerDefaultBudget, createBatchEntry, getProtocolMethod, getMinCursor, onSignalAbort, parsePullArgs, + splitBatchEntry, validateBatchEntry, + validateBudget, } = require('internal/streams/iter/utils'); const { converters, @@ -59,12 +62,10 @@ const { ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE, ERR_INVALID_RETURN_VALUE, + ERR_INVALID_STATE, ERR_OUT_OF_RANGE, }, } = require('internal/errors'); -const { - validateInteger, -} = require('internal/validators'); const { markPromiseAsHandled } = internalBinding('util'); @@ -175,7 +176,7 @@ class ShareImpl { for (;;) { if (state.detached) { if (state.error !== kNoShareError) throw state.error; - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } if (self.#cancelled) { @@ -183,7 +184,7 @@ class ShareImpl { state.error = self.#cancelError; self.#deleteConsumer(state); if (state.error !== kNoShareError) throw state.error; - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } // Check if data is available in buffer @@ -196,7 +197,7 @@ class ShareImpl { --self.#cachedMinCursorConsumers === 0) { self.#tryTrimBuffer(); } - return { __proto__: null, done: false, value: chunk }; + return new IterResult(false, chunk); } if (self.#sourceExhausted) { @@ -206,7 +207,7 @@ class ShareImpl { state.error = self.#sourceError; throw state.error; } - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } // Need to pull from source - check buffer limit @@ -225,7 +226,7 @@ class ShareImpl { state.error = self.#cancelError; self.#deleteConsumer(state); if (state.error !== kNoShareError) throw state.error; - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } await self.#pullFromSource(!shouldBuffer); @@ -235,8 +236,7 @@ class ShareImpl { } }; - return { - __proto__: null, + return ObjectSetPrototypeOf({ next() { const next = PromisePrototypeThen( state.pendingNext, @@ -254,7 +254,7 @@ class ShareImpl { if (self.#deleteConsumer(state)) { self.#tryTrimBuffer(); } - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); }, async throw() { @@ -264,9 +264,9 @@ class ShareImpl { if (self.#deleteConsumer(state)) { self.#tryTrimBuffer(); } - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); }, - }; + }, null); }, }; } @@ -303,7 +303,7 @@ class ShareImpl { if (hasReason) { consumer.reject?.(reason); } else { - consumer.resolve({ __proto__: null, done: true, value: undefined }); + consumer.resolve(new IterResult(true, undefined)); } consumer.resolve = null; consumer.reject = null; @@ -401,16 +401,15 @@ class ShareImpl { } else if (isSyncIterable(this.#source)) { const syncIterator = this.#source[SymbolIterator](); - this.#sourceIterator = { - __proto__: null, + this.#sourceIterator = ObjectSetPrototypeOf({ async next() { return syncIterator.next(); }, async return() { return syncIterator.return?.() ?? - { __proto__: null, done: true, value: undefined }; + new IterResult(true, undefined); }, - }; + }, null); } else { throw new ERR_INVALID_ARG_TYPE( 'source', ['AsyncIterable', 'Iterable'], this.#source); @@ -427,9 +426,7 @@ class ShareImpl { if (result.done) { this.#sourceExhausted = true; } else if (!discard) { - const entry = createBatchEntry(result.value); - this.#buffer.push(entry); - this.#bufferedBytes += entry.byteLength; + this.#bufferBatch(result.value); } } catch (error) { this.#sourceError = error; @@ -447,7 +444,31 @@ class ShareImpl { })(); } + #bufferBatch(batch) { + const entry = createBatchEntry(batch); + // 'drop-oldest' evicts whole entries. A single pulled batch can be much + // larger than the budget (for example when from() combines many values + // of a sync source), and evicting it would discard every chunk in it, + // including ones that no consumer has read. Split such batches so that + // eviction keeps the newest chunks that fit the budget. + const entries = this.#options.backpressure === 'drop-oldest' ? + splitBatchEntry(entry, this.#options.budget) : undefined; + if (entries === undefined) { + this.#buffer.push(entry); + } else { + for (let i = 0; i < entries.length; i++) { + this.#buffer.push(entries[i]); + } + } + this.#bufferedBytes += entry.byteLength; + } + #tryTrimBuffer() { + // Retain buffered data for consumers that attach while none are active. + // Without this, the last consumer detaching would discard entries that + // a late-joining consumer has not read and cannot get back from the + // source. + if (this.#consumers.size === 0) return; if (this.#cachedMinCursorConsumers === 0) { this.#recomputeMinCursor(); } @@ -525,6 +546,7 @@ class SyncShareImpl { #sourceExhausted = false; #sourceError = kNoShareError; #cancelled = false; + #pulling = false; #cachedMinCursor = 0; #cachedMinCursorConsumers = 0; /** Cumulative byte size of buffered entries */ @@ -569,12 +591,11 @@ class SyncShareImpl { return { __proto__: null, [SymbolIterator]() { - return { - __proto__: null, + return ObjectSetPrototypeOf({ next() { if (state.detached) { if (state.error !== kNoShareError) throw state.error; - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } if (self.#sourceError !== kNoShareError) { state.detached = true; @@ -585,7 +606,7 @@ class SyncShareImpl { if (self.#cancelled) { state.detached = true; self.#deleteConsumer(state); - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } const bufferIndex = state.cursor - self.#bufferStart; @@ -597,23 +618,33 @@ class SyncShareImpl { --self.#cachedMinCursorConsumers === 0) { self.#tryTrimBuffer(); } - return { __proto__: null, done: false, value: chunk }; + return new IterResult(false, chunk); } if (self.#sourceExhausted) { state.detached = true; self.#deleteConsumer(state); - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } - // Check buffer limit - let dropped = false; + // Check buffer limit. 'unbounded' and 'drop-newest' are rejected + // by shareSync(). if (self.#bufferedBytes >= self.#options.budget) { switch (self.#options.backpressure) { - case 'strict': - throw new ERR_OUT_OF_RANGE( + case 'strict': { + const error = new ERR_OUT_OF_RANGE( 'buffered bytes', `< ${self.#options.budget}`, self.#bufferedBytes); + // Detach before throwing, as the async share does. Neither + // for...of nor a transform pipeline calls return() when + // next() throws, so a consumer left registered here would + // keep its cursor forever and wedge the other consumers. + state.detached = true; + if (self.#deleteConsumer(state)) { + self.#tryTrimBuffer(); + } + throw error; + } case 'drop-oldest': while (self.#bufferedBytes >= self.#options.budget && self.#buffer.length > 0) { @@ -629,21 +660,10 @@ class SyncShareImpl { } self.#recomputeMinCursor(); break; - case 'drop-newest': - // Discarding does not reclaim budget, and the slowest - // consumer cannot advance while this synchronous next() is - // running, so at most one entry may be dropped per call. - // Looping here would spin forever on an unbounded source - // and drain a finite one in a single call. - self.#pullFromSource(true); - dropped = true; - break; } } - if (!dropped) { - self.#pullFromSource(); - } + self.#pullFromSource(); if (self.#sourceError !== kNoShareError) { state.detached = true; @@ -661,16 +681,16 @@ class SyncShareImpl { --self.#cachedMinCursorConsumers === 0) { self.#tryTrimBuffer(); } - return { __proto__: null, done: false, value: chunk }; + return new IterResult(false, chunk); } if (self.#sourceExhausted) { state.detached = true; self.#deleteConsumer(state); - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); } - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); }, return() { @@ -678,7 +698,7 @@ class SyncShareImpl { if (self.#deleteConsumer(state)) { self.#tryTrimBuffer(); } - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); }, throw() { @@ -686,9 +706,9 @@ class SyncShareImpl { if (self.#deleteConsumer(state)) { self.#tryTrimBuffer(); } - return { __proto__: null, done: true, value: undefined }; + return new IterResult(true, undefined); }, - }; + }, null); }, }; } @@ -722,9 +742,18 @@ class SyncShareImpl { this.cancel(); } - #pullFromSource(discard = false) { + #pullFromSource() { if (this.#sourceExhausted || this.#cancelled) return; + // A source that reads from its own share would re-enter its own + // iterator. Fail that read without touching the share's state; the + // source sees the error and may handle it. + if (this.#pulling) { + throw new ERR_INVALID_STATE( + 'shareSync() source cannot be read while it is producing a value'); + } + + this.#pulling = true; try { this.#sourceIterator ||= this.#source[SymbolIterator](); @@ -732,18 +761,42 @@ class SyncShareImpl { if (result.done) { this.#sourceExhausted = true; - } else if (!discard) { - const entry = createBatchEntry(result.value); - this.#buffer.push(entry); - this.#bufferedBytes += entry.byteLength; + } else { + this.#bufferBatch(result.value); } } catch (error) { this.#sourceError = error; this.#sourceExhausted = true; + } finally { + this.#pulling = false; + } + } + + #bufferBatch(batch) { + const entry = createBatchEntry(batch); + // 'drop-oldest' evicts whole entries. A single pulled batch can be much + // larger than the budget (for example when from() combines many values + // of a sync source), and evicting it would discard every chunk in it, + // including ones that no consumer has read. Split such batches so that + // eviction keeps the newest chunks that fit the budget. + const entries = this.#options.backpressure === 'drop-oldest' ? + splitBatchEntry(entry, this.#options.budget) : undefined; + if (entries === undefined) { + this.#buffer.push(entry); + } else { + for (let i = 0; i < entries.length; i++) { + this.#buffer.push(entries[i]); + } } + this.#bufferedBytes += entry.byteLength; } #tryTrimBuffer() { + // Retain buffered data for consumers that attach while none are active. + // Without this, the last consumer detaching would discard entries that + // a late-joining consumer has not read and cannot get back from the + // source. + if (this.#consumers.size === 0) return; if (this.#cachedMinCursorConsumers === 0) { this.#recomputeMinCursor(); } @@ -808,7 +861,7 @@ function share(source, options = { __proto__: null }) { backpressure = 'strict', signal, } = options; - validateInteger(budget, 'options.budget', 16384); + validateBudget(budget); const opts = { __proto__: null, @@ -837,11 +890,15 @@ function shareSync(source, options = { __proto__: null }) { budget = kMultiConsumerDefaultBudget, backpressure = 'strict', } = options; - validateInteger(budget, 'options.budget', 16384); - if (backpressure === 'unbounded') { + validateBudget(budget); + // A synchronous consumer can neither wait for the slowest consumer to + // release budget ('unbounded') nor keep pulling and discarding until it + // does ('drop-newest'): the slowest consumer cannot advance while the + // call is running. + if (backpressure === 'unbounded' || backpressure === 'drop-newest') { throw new ERR_INVALID_ARG_VALUE( 'options.backpressure', backpressure, - 'unbounded is not supported by shareSync()'); + `${backpressure} is not supported by shareSync()`); } const opts = { diff --git a/lib/internal/streams/iter/transform.js b/lib/internal/streams/iter/transform.js index 2cd06957b800..e72392785006 100644 --- a/lib/internal/streams/iter/transform.js +++ b/lib/internal/streams/iter/transform.js @@ -10,8 +10,7 @@ const { ArrayPrototypeMap, - ArrayPrototypePush, - ArrayPrototypeShift, + ArrayPrototypeSlice, FunctionPrototypeCall, MathMax, NumberIsNaN, @@ -40,6 +39,7 @@ const { } = require('internal/errors'); const { isArrayBufferView, isAnyArrayBuffer } = require('internal/util/types'); const { kValidatedTransform } = require('internal/streams/iter/types'); +const { kNullOnceOption } = require('internal/streams/iter/utils'); const { checkRangesOrGetDefault, kValidateObjectAllowArray, @@ -310,6 +310,8 @@ function makeZlibTransform(createHandleFn, processFlag, finishFlag) { let outOffset = 0; let chunkSize; let pending = []; + // Index of the first buffer of `pending` not yet taken by drainBatch(). + let pendingHead = 0; let pendingBytes = 0; // Current write operation state (read by the callback for looping). @@ -330,18 +332,16 @@ function makeZlibTransform(createHandleFn, processFlag, finishFlag) { if (have > 0) { if (bufferExhausted && outOffset === 0) { // Entire buffer filled from start - yield directly, no copy. - ArrayPrototypePush(pending, outBuf); + pending[pending.length] = outBuf; } else if (bufferExhausted) { // Tail of buffer filled and buffer is being replaced - // subarray is safe since outBuf reference is overwritten below. - ArrayPrototypePush(pending, - outBuf.subarray(outOffset, outOffset + have)); + pending[pending.length] = + outBuf.subarray(outOffset, outOffset + have); } else { // Partial fill, buffer will be reused - must copy. - ArrayPrototypePush(pending, - TypedArrayPrototypeSlice(outBuf, - outOffset, - outOffset + have)); + pending[pending.length] = + TypedArrayPrototypeSlice(outBuf, outOffset, outOffset + have); } pendingBytes += have; outOffset += have; @@ -413,7 +413,7 @@ function makeZlibTransform(createHandleFn, processFlag, finishFlag) { reject(signal.reason); } }; - signal.addEventListener('abort', onAbort, { __proto__: null, once: true }); + signal.addEventListener('abort', onAbort, kNullOnceOption); function continueInputAsync() { const { promise, resolve, reject } = PromiseWithResolvers(); @@ -441,20 +441,33 @@ function makeZlibTransform(createHandleFn, processFlag, finishFlag) { function drainBatch() { if (pendingBytes <= BATCH_HWM) { - // Swap instead of splice - avoids copying the array. - const batch = pending; + // Take everything: swap instead of copying, unless batches have + // already been taken from the front. + const batch = pendingHead === 0 ? pending : + ArrayPrototypeSlice(pending, pendingHead); pending = []; + pendingHead = 0; pendingBytes = 0; return batch; } - const batch = []; + // Take up to BATCH_HWM bytes from the front by advancing pendingHead: + // shifting them off instead would copy the rest of the array, which + // makes draining a large output quadratic. + let end = pendingHead; let batchBytes = 0; - while (pending.length > 0 && batchBytes < BATCH_HWM) { - const buf = ArrayPrototypeShift(pending); - ArrayPrototypePush(batch, buf); - const len = TypedArrayPrototypeGetByteLength(buf); - batchBytes += len; - pendingBytes -= len; + while (end < pending.length && batchBytes < BATCH_HWM) { + batchBytes += TypedArrayPrototypeGetByteLength(pending[end]); + end++; + } + const batch = ArrayPrototypeSlice(pending, pendingHead, end); + pendingBytes -= batchBytes; + if (end === pending.length) { + pending = []; + pendingHead = 0; + } else { + // Do not keep the buffers taken alive. + for (let i = pendingHead; i < end; i++) pending[i] = undefined; + pendingHead = end; } return batch; } @@ -510,7 +523,7 @@ function makeZlibTransform(createHandleFn, processFlag, finishFlag) { processInputBatches(kEmpty, finishFlag)) { yield batch; } - while (pending.length > 0) { + while (pending.length > pendingHead) { yield drainBatch(); } } @@ -530,11 +543,12 @@ function makeZlibTransform(createHandleFn, processFlag, finishFlag) { } if (pendingBytes >= BATCH_HWM) { - while (pending.length > 0 && pendingBytes >= BATCH_HWM) { + while (pending.length > pendingHead && + pendingBytes >= BATCH_HWM) { yield drainBatch(); } } - if (pending.length > 0) { + if (pending.length > pendingHead) { yield drainBatch(); } } @@ -546,7 +560,7 @@ function makeZlibTransform(createHandleFn, processFlag, finishFlag) { processInputBatches(kEmpty, finishFlag)) { yield batch; } - while (pending.length > 0) { + while (pending.length > pendingHead) { yield drainBatch(); } } @@ -601,6 +615,8 @@ function makeZlibTransformSync(createHandleFn, processFlag, finishFlag) { let outBuf = Buffer.allocUnsafe(chunkSize); let outOffset = 0; let pending = []; + // Index of the first buffer of `pending` not yet taken by drainBatch(). + let pendingHead = 0; let pendingBytes = 0; function processSyncInput(input, flushFlag) { @@ -623,17 +639,15 @@ function makeZlibTransformSync(createHandleFn, processFlag, finishFlag) { if (have > 0) { if (bufferExhausted && outOffset === 0) { // Entire buffer filled - yield directly, no copy. - ArrayPrototypePush(pending, outBuf); + pending[pending.length] = outBuf; } else if (bufferExhausted) { // Tail filled, buffer being replaced - subarray is safe. - ArrayPrototypePush(pending, - outBuf.subarray(outOffset, outOffset + have)); + pending[pending.length] = + outBuf.subarray(outOffset, outOffset + have); } else { // Partial fill, buffer reused - must copy. - ArrayPrototypePush(pending, - TypedArrayPrototypeSlice(outBuf, - outOffset, - outOffset + have)); + pending[pending.length] = + TypedArrayPrototypeSlice(outBuf, outOffset, outOffset + have); } pendingBytes += have; outOffset += have; @@ -665,19 +679,33 @@ function makeZlibTransformSync(createHandleFn, processFlag, finishFlag) { function drainBatch() { if (pendingBytes <= BATCH_HWM) { - const batch = pending; + // Take everything: swap instead of copying, unless batches have + // already been taken from the front. + const batch = pendingHead === 0 ? pending : + ArrayPrototypeSlice(pending, pendingHead); pending = []; + pendingHead = 0; pendingBytes = 0; return batch; } - const batch = []; + // Take up to BATCH_HWM bytes from the front by advancing pendingHead: + // shifting them off instead would copy the rest of the array, which + // makes draining a large output quadratic. + let end = pendingHead; let batchBytes = 0; - while (pending.length > 0 && batchBytes < BATCH_HWM) { - const buf = ArrayPrototypeShift(pending); - const len = TypedArrayPrototypeGetByteLength(buf); - ArrayPrototypePush(batch, buf); - batchBytes += len; - pendingBytes -= len; + while (end < pending.length && batchBytes < BATCH_HWM) { + batchBytes += TypedArrayPrototypeGetByteLength(pending[end]); + end++; + } + const batch = ArrayPrototypeSlice(pending, pendingHead, end); + pendingBytes -= batchBytes; + if (end === pending.length) { + pending = []; + pendingHead = 0; + } else { + // Do not keep the buffers taken alive. + for (let i = pendingHead; i < end; i++) pending[i] = undefined; + pendingHead = end; } return batch; } @@ -687,7 +715,7 @@ function makeZlibTransformSync(createHandleFn, processFlag, finishFlag) { if (batch === null) { // Flush signal - finalize the engine. processSyncInput(Buffer.alloc(0), finishFlag); - while (pending.length > 0) { + while (pending.length > pendingHead) { yield drainBatch(); } continue; @@ -698,11 +726,12 @@ function makeZlibTransformSync(createHandleFn, processFlag, finishFlag) { } if (pendingBytes >= BATCH_HWM) { - while (pending.length > 0 && pendingBytes >= BATCH_HWM) { + while (pending.length > pendingHead && + pendingBytes >= BATCH_HWM) { yield drainBatch(); } } - if (pending.length > 0) { + if (pending.length > pendingHead) { yield drainBatch(); } } diff --git a/lib/internal/streams/iter/utils.js b/lib/internal/streams/iter/utils.js index a6069c9ed3b7..6b40f5a2903a 100644 --- a/lib/internal/streams/iter/utils.js +++ b/lib/internal/streams/iter/utils.js @@ -4,11 +4,16 @@ const { Array, ArrayBufferPrototypeGetByteLength, ArrayBufferPrototypeGetDetached, + ArrayBufferPrototypeGetResizable, ArrayPrototypeSlice, + FunctionPrototypeCall, + ObjectDefineProperty, + ObjectFreeze, + ObjectSetPrototypeOf, + PromisePrototypeThen, + PromiseReject, PromiseResolve, PromiseWithResolvers, - SafePromisePrototypeFinally, - SafePromiseRace, SafeWeakSet, SymbolAsyncIterator, TypedArrayPrototypeGetBuffer, @@ -31,8 +36,11 @@ const { } = require('internal/errors'); const { isSharedArrayBuffer, isUint8Array } = require('internal/util/types'); +const { kWeakHandler } = require('internal/event_target'); +const { RingBuffer } = require('internal/streams/iter/ringbuffer'); const { + validateInteger, validateOneOf, } = require('internal/validators'); const { @@ -42,6 +50,11 @@ const { kValidatedTransform, } = require('internal/streams/iter/types'); +// Shared `addEventListener()` options for one-time 'abort' listeners. Frozen +// because signals can come from user code, and a patched addEventListener() +// must not be able to change the options for every later registration. +const kNullOnceOption = ObjectFreeze({ __proto__: null, once: true }); + // Cached resolved promise to avoid allocating a new one on every sync fast-path. const kResolvedPromise = PromiseResolve(); @@ -58,6 +71,30 @@ const kPushDefaultBudget = 16384; /** Default byte budget for broadcast and share streams (multi-consumer). */ const kMultiConsumerDefaultBudget = 65536; +/** + * Iterator result object (`{ done, value }`) for the iterators returned by + * this module. A result is created for every chunk, so it must be cheap. + * + * `new IterResult(done, value)` literals are created in V8 dictionary + * mode, which costs several times more than an ordinary object. Instances + * of this constructor have fast properties, always with the same shape + * (`done` before `value`), and still have no %Object.prototype% in their + * prototype chain, so a polluted `Object.prototype.then` cannot turn a + * result into a thenable when an async `next()` resolves with it. The + * prototype is a single empty, frozen, null-prototype object (V8 gives + * objects fast properties once they are used as a prototype). + * @param {boolean} done + * @param {any} value + */ +function IterResult(done, value) { + this.done = done; + this.value = value; +} +// The non-enumerable `constructor` lets util.inspect() print results as +// `IterResult { done, value }`. +IterResult.prototype = ObjectFreeze(ObjectDefineProperty( + { __proto__: null }, 'constructor', { __proto__: null, value: IterResult })); + /** * Register a handler for an AbortSignal, handling the already-aborted case. * If the signal is already aborted, calls handler immediately. @@ -69,38 +106,8 @@ function onSignalAbort(signal, handler) { if (signal.aborted) { handler(); } else { - signal.addEventListener('abort', handler, { __proto__: null, once: true }); - } -} - -function getOnAbort(reject, signal) { - return () => reject(signal.reason); -} - -/** - * Read one item from an async iterator, rejecting early if the signal aborts. - * @param {AsyncIterator} iterator - The iterator to read from. - * @param {AbortSignal|undefined} signal - Optional abort signal. - * @returns {Promise>|IteratorResult} - */ -function abortableNext(iterator, signal) { - if (signal === undefined) { - return iterator.next(); + signal.addEventListener('abort', handler, kNullOnceOption); } - - signal.throwIfAborted(); - - const next = iterator.next(); - const { promise, reject } = PromiseWithResolvers(); - const onAbort = getOnAbort(reject, signal); - signal.addEventListener('abort', onAbort, { __proto__: null, once: true }); - if (signal.aborted) { - onAbort(); - } - - return SafePromisePrototypeFinally(SafePromiseRace([next, promise]), () => { - signal.removeEventListener('abort', onAbort); - }); } /** @@ -116,39 +123,202 @@ function yieldAbortable(source, signal) { return { __proto__: null, - async *[SymbolAsyncIterator]() { - const iterator = source[SymbolAsyncIterator](); - let completed = false; - let aborted = false; + [SymbolAsyncIterator]() { + return createAbortableIterator(source, signal); + }, + }; +} + +/** + * Iterator reading `source` until `signal` aborts, for yieldAbortable(). + * + * This is what an async generator looping over the source with + * abortableNext() would do, written out by hand: that costs an abort + * listener, a promise race and an async generator layer for every value. + * Instead, a single abort listener is added for the whole iteration, held + * weakly so that the signal does not keep the iterator alive, and each + * next() waits with a single promise that an abort rejects. As with the + * generator: + * - the source is opened by the first next(), and calls made while one is in + * progress are queued; + * - an abort before or while reading a value, or once it has been read, + * rejects with the abort reason; + * - unless the source has ended, an error (including an abort) closes it: + * if the signal is aborted, without waiting, otherwise waiting and + * rejecting with an error from closing it instead; + * - return() closes the source, propagating errors from closing it. + * @param {AsyncIterable} source + * @param {AbortSignal} signal + * @returns {AsyncIterator} + */ +function createAbortableIterator(source, signal) { + let state = kStart; + let iterator; + let completed = false; + // The settling functions of the pending next(), if any. + let resolveNext = null; + let rejectNext = null; + const operations = createOperationQueue(); + + const self = ObjectSetPrototypeOf({ + next() { return operations.run(doNext); }, + return(value) { return operations.run(doReturn, value); }, + throw(error) { return operations.run(doThrow, error); }, + [SymbolAsyncIterator]() { return this; }, + }, null); + + function onAbort() { + if (rejectNext === null) return; + const reject = rejectNext; + resolveNext = rejectNext = null; + fail(reject, signal.reason); + } + // What the generator did in its `catch` and `finally` blocks for `error`. + function fail(reject, error) { + state = kDone; + signal.removeEventListener('abort', onAbort); + const aborted = signal.aborted; + if (!completed && typeof iterator.return === 'function') { + let closed; try { - while (true) { - const { done, value } = await abortableNext(iterator, signal); - if (done) { - completed = true; - return; - } - signal.throwIfAborted(); - yield value; + const result = iterator.return(); + if (aborted) { + // PromiseResolve(result) can reject if result is a thenable that + // rejects, so mark it as handled even though the abort takes + // precedence over the result of iterator.return(). + markPromiseAsHandled(PromiseResolve(result)); + } else { + closed = PromiseResolve(result); } + } catch (closeError) { + operations.settled(); + reject(closeError); + return; + } + if (closed !== undefined) { + PromisePrototypeThen(closed, () => { + operations.settled(); + reject(error); + }, (closeError) => { + operations.settled(); + reject(closeError); + }); + return; + } + } + operations.settled(); + reject(error); + } + + function onResult(result) { + if (resolveNext === null) return; // Settled by an abort. + const resolve = resolveNext; + const reject = rejectNext; + resolveNext = rejectNext = null; + let done; + let value; + try { + ({ done, value } = result); + if (!done) signal.throwIfAborted(); + } catch (error) { + fail(reject, error); + return; + } + if (done) { + completed = true; + state = kDone; + signal.removeEventListener('abort', onAbort); + operations.settled(); + resolve(new IterResult(true, undefined)); + return; + } + operations.settled(); + resolve(new IterResult(false, value)); + } + + function onError(error) { + if (rejectNext === null) return; // Settled by an abort. + const reject = rejectNext; + resolveNext = rejectNext = null; + fail(reject, error); + } + + function doNext() { + if (state === kDone) { + operations.settled(); + return PromiseResolve(new IterResult(true, undefined)); + } + if (state === kStart) { + try { + iterator = source[SymbolAsyncIterator](); } catch (error) { - aborted = signal.aborted; - throw error; - } finally { - if (!completed && typeof iterator.return === 'function') { - const result = iterator.return(); - if (aborted) { - // PromiseResolve(result) can reject if result is a thenable that - // rejects, so mark it as handled even though the abort takes - // precedence over the result of iterator.return(). - markPromiseAsHandled(PromiseResolve(result)); - } else { - await result; - } - } + state = kDone; + operations.settled(); + return PromiseReject(error); } - }, - }; + state = kActive; + signal.addEventListener('abort', onAbort, + { __proto__: null, [kWeakHandler]: self }); + } + const { promise, resolve, reject } = PromiseWithResolvers(); + let next; + try { + signal.throwIfAborted(); + next = PromiseResolve(iterator.next()); + } catch (error) { + fail(reject, error); + return promise; + } + resolveNext = resolve; + rejectNext = reject; + PromisePrototypeThen(next, onResult, onError); + // The signal can abort while iterator.next() runs. + if (signal.aborted) onAbort(); + return promise; + } + + function doReturn(value) { + const result = new IterResult(true, value); + if (state !== kActive) { + state = kDone; + operations.settled(); + return PromiseResolve(result); + } + state = kDone; + signal.removeEventListener('abort', onAbort); + if (typeof iterator.return !== 'function') { + operations.settled(); + return PromiseResolve(result); + } + let closed; + try { + closed = PromiseResolve(iterator.return()); + } catch (error) { + operations.settled(); + return PromiseReject(error); + } + return PromisePrototypeThen(closed, () => { + operations.settled(); + return result; + }, (error) => { + operations.settled(); + throw error; + }); + } + + function doThrow(error) { + if (state !== kActive) { + state = kDone; + operations.settled(); + return PromiseReject(error); + } + const { promise, reject } = PromiseWithResolvers(); + fail(reject, error); + return promise; + } + + return self; } /** @@ -188,26 +358,77 @@ function toUint8Array(chunk) { return chunk; } +// Byte view snapshots and batch entries are created for every chunk and +// every batch, so like IterResult they are constructed rather than created +// as `{ __proto__: null, ... }` literals (which are dictionary-mode objects), +// and their prototype is an empty null-prototype object. +function ByteViewSnapshot(value, buffer, sharedBufferView) { + this.value = value; + this.buffer = buffer; + this.bufferByteLength = sharedBufferView === undefined ? + ArrayBufferPrototypeGetByteLength(buffer) : + TypedArrayPrototypeGetByteLength(sharedBufferView); + this.byteLength = TypedArrayPrototypeGetByteLength(value); + this.byteOffset = TypedArrayPrototypeGetByteOffset(value); + this.detached = sharedBufferView === undefined && + ArrayBufferPrototypeGetDetached(buffer); + this.sharedBufferView = sharedBufferView; +} +ByteViewSnapshot.prototype = ObjectFreeze({ __proto__: null }); + +// The snapshot of a non-empty view of a fixed-length, non-shared ArrayBuffer, +// the common case. Such a view can only change by the buffer being detached, +// which makes its byteLength 0, so its byteLength is all that needs to be +// recorded. `buffer` is null to tell it from a ByteViewSnapshot. +function FixedByteView(value, byteLength) { + this.value = value; + this.byteLength = byteLength; + this.buffer = null; +} +FixedByteView.prototype = ObjectFreeze({ __proto__: null }); + +function BatchEntry(views, byteLength) { + this.views = views; + this.byteLength = byteLength; +} +BatchEntry.prototype = ObjectFreeze({ __proto__: null }); + +// Waiters for reads, writes and drains are queued whenever a stream has +// to wait, which can be once per chunk. +function PendingRequest(resolve, reject) { + this.resolve = resolve; + this.reject = reject; +} +PendingRequest.prototype = ObjectFreeze({ __proto__: null }); + +function PendingWrite(batch, resolve, reject) { + this.batch = batch; + this.resolve = resolve; + this.reject = reject; +} +PendingWrite.prototype = ObjectFreeze({ __proto__: null }); + 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, - }; + if (isSharedArrayBuffer(buffer)) { + return new ByteViewSnapshot(value, buffer, new Uint8Array(buffer)); + } + const byteLength = TypedArrayPrototypeGetByteLength(value); + if (byteLength !== 0 && !ArrayBufferPrototypeGetResizable(buffer)) { + return new FixedByteView(value, byteLength); + } + return new ByteViewSnapshot(value, buffer, undefined); } function validateByteView(snapshot) { + if (snapshot.buffer === null) { + const { value } = snapshot; + if (TypedArrayPrototypeGetByteLength(value) !== snapshot.byteLength) { + throw new ERR_INVALID_STATE.TypeError( + 'Byte view was resized or detached after being accepted'); + } + return value; + } const { value, buffer, @@ -234,6 +455,238 @@ function validateByteView(snapshot) { return value; } +/** + * Call `method` on `receiver` with the byte view `value`, and throw if the + * call resized or detached it, as validateByteView() does for a snapshot + * taken just before the call. Used for single-chunk writes, the common case: + * for views on an ArrayBuffer the snapshot is kept in locals instead of a + * ByteViewSnapshot, so that nothing is allocated per chunk. + * @param {Uint8Array} value + * @param {Function} method + * @param {object} receiver + * @returns {any} The result of the call. + */ +function callWithByteView(value, method, receiver) { + const buffer = TypedArrayPrototypeGetBuffer(value); + if (!isSharedArrayBuffer(buffer) && !ArrayBufferPrototypeGetResizable(buffer)) { + // A non-empty view of a fixed-length, non-shared ArrayBuffer can only + // change by the buffer being detached, which makes its byteLength 0 (see + // FixedByteView), so checking its byteLength is enough. + const byteLength = TypedArrayPrototypeGetByteLength(value); + if (byteLength !== 0) { + const result = FunctionPrototypeCall(method, receiver, value); + if (TypedArrayPrototypeGetByteLength(value) !== byteLength) { + throw new ERR_INVALID_STATE.TypeError( + 'Byte view was resized or detached after being accepted'); + } + return result; + } + } + if (isSharedArrayBuffer(buffer)) { + const snapshot = snapshotByteView(value); + const result = FunctionPrototypeCall(method, receiver, value); + validateByteView(snapshot); + return result; + } + const bufferByteLength = ArrayBufferPrototypeGetByteLength(buffer); + const byteLength = TypedArrayPrototypeGetByteLength(value); + const byteOffset = TypedArrayPrototypeGetByteOffset(value); + const detached = ArrayBufferPrototypeGetDetached(buffer); + const result = FunctionPrototypeCall(method, receiver, value); + if (TypedArrayPrototypeGetBuffer(value) !== buffer || + ArrayBufferPrototypeGetByteLength(buffer) !== bufferByteLength || + TypedArrayPrototypeGetByteLength(value) !== byteLength || + TypedArrayPrototypeGetByteOffset(value) !== byteOffset || + ArrayBufferPrototypeGetDetached(buffer) !== detached) { + throw new ERR_INVALID_STATE.TypeError( + 'Byte view was resized or detached after being accepted'); + } + return result; +} + +// States of the hand-written async iterators that replace async generators +// on hot paths. +const kStart = 0; +const kActive = 1; +const kDone = 2; + +class QueuedOperation { + constructor(method, arg, resolve, reject) { + this.method = method; + this.arg = arg; + this.resolve = resolve; + this.reject = reject; + } +} + +/** + * Serializes the operations of a hand-written async iterator the way an + * async generator queues its requests: an operation started while another + * is in progress waits until it has settled. + * @returns {{ run: Function, settled: Function, release: Function }} + */ +function createOperationQueue() { + let busy = false; + // Created on first use, then kept: queued operations, a RingBuffer of + // QueuedOperation, since as many can be queued as calls are made. + let queue = null; + + function drain() { + if (busy || queue === null || queue.length === 0) return; + const { method, arg, resolve, reject } = queue.shift(); + busy = true; + PromisePrototypeThen(method(arg), resolve, reject); + } + + return { + __proto__: null, + // Run `method(arg)`, which returns a promise and must call settled() + // once its result is known, now or after the operations before it. + run(method, arg) { + if (busy || (queue !== null && queue.length !== 0)) { + const { promise, resolve, reject } = PromiseWithResolvers(); + queue ??= new RingBuffer(); + queue.push(new QueuedOperation(method, arg, resolve, reject)); + return promise; + } + busy = true; + return method(arg); + }, + settled() { + busy = false; + if (queue !== null && queue.length !== 0) { + PromisePrototypeThen(kResolvedPromise, drain); + } + }, + // Like settled(), but start the next queued operation now, as an async + // generator does once the await of a yield or return completes. + release() { + busy = false; + drain(); + }, + // Whether no operation is running or queued. + idle() { + return !busy && (queue === null || queue.length === 0); + }, + }; +} + +/** + * Validate an explicit `budget` option. The spec only requires the default + * budget to be at least 16384 bytes; any positive explicit budget is valid. + * @param {unknown} budget + */ +function validateBudget(budget) { + validateInteger(budget, 'options.budget', 1); +} + +/** + * Append a chunk to `chunks` for later concatenation, and the information + * needed to detect that it was resized or detached in the meantime to + * `checks`. This provides the same guarantee as snapshotByteView() and + * validateByteView() without allocating a snapshot for every chunk in the + * common case: a non-empty view of a fixed-length, non-shared ArrayBuffer can + * only change by the buffer being detached, which makes its byteLength 0, so + * its byteLength is all that needs to be recorded. + * @param {Uint8Array[]} chunks + * @param {Array} checks + * @param {Uint8Array} value + * @returns {number} The byteLength of `value`. + */ +function recordChunk(chunks, checks, value) { + const buffer = TypedArrayPrototypeGetBuffer(value); + const byteLength = TypedArrayPrototypeGetByteLength(value); + chunks[chunks.length] = value; + if (byteLength === 0 || isSharedArrayBuffer(buffer) || + ArrayBufferPrototypeGetResizable(buffer)) { + checks[checks.length] = snapshotByteView(value); + } else { + checks[checks.length] = byteLength; + } + return byteLength; +} + +/** + * Validate chunks recorded with recordChunk(). + * @param {Uint8Array[]} chunks + * @param {Array} checks + * @returns {Uint8Array[]} `chunks` + */ +function validateRecordedChunks(chunks, checks) { + for (let i = 0; i < chunks.length; i++) { + const check = checks[i]; + if (typeof check !== 'number') { + validateByteView(check); + } else if (TypedArrayPrototypeGetByteLength(chunks[i]) !== check) { + throw new ERR_INVALID_STATE.TypeError( + 'Byte view was resized or detached after being accepted'); + } + } + return chunks; +} + +// A batch accepted for writing one chunk at a time: its chunks, and their +// byteLengths when accepted (see snapshotFixedBatch()). +function FixedBatch(chunks, byteLengths, byteLength) { + this.chunks = chunks; + this.byteLengths = byteLengths; + this.byteLength = byteLength; +} +FixedBatch.prototype = ObjectFreeze({ __proto__: null }); + +/** + * Snapshot a batch of Uint8Arrays like createBatchEntry(), without an object + * for every chunk, when every chunk is a non-empty view of a fixed-length, + * non-shared ArrayBuffer: such views can only change by being detached, + * which makes their byteLength 0 (see FixedByteView), so their byteLengths + * are all that needs to be recorded. Check a chunk with checkFixedBatchChunk() + * before and after using it. + * @param {Uint8Array[]} chunks + * @returns {FixedBatch|null} null if a chunk needs a full snapshot. + */ +function snapshotFixedBatch(chunks) { + const count = chunks.length; + const byteLengths = new Array(count); + let byteLength = 0; + let checkedBuffer; + for (let i = 0; i < count; i++) { + const view = chunks[i]; + const buffer = TypedArrayPrototypeGetBuffer(view); + // Chunks often share a buffer: check each buffer once. + if (buffer !== checkedBuffer) { + if (isSharedArrayBuffer(buffer) || + ArrayBufferPrototypeGetResizable(buffer)) { + return null; + } + checkedBuffer = buffer; + } + const length = TypedArrayPrototypeGetByteLength(view); + if (length === 0) return null; + byteLengths[i] = length; + byteLength += length; + } + return new FixedBatch(ArrayPrototypeSlice(chunks), byteLengths, byteLength); +} + +function checkFixedBatchChunk(batch, index) { + const chunk = batch.chunks[index]; + if (TypedArrayPrototypeGetByteLength(chunk) !== batch.byteLengths[index]) { + throw new ERR_INVALID_STATE.TypeError( + 'Byte view was resized or detached after being accepted'); + } + return chunk; +} + +// The batch entry of a FixedBatch, to continue writing it asynchronously. +function fixedBatchToEntry(batch) { + const { chunks, byteLengths } = batch; + const views = new Array(chunks.length); + for (let i = 0; i < chunks.length; i++) { + views[i] = new FixedByteView(chunks[i], byteLengths[i]); + } + return new BatchEntry(views, batch.byteLength); +} + function createBatchEntry(chunks) { const views = new Array(chunks.length); let byteLength = 0; @@ -242,7 +695,35 @@ function createBatchEntry(chunks) { views[i] = view; byteLength += view.byteLength; } - return { __proto__: null, views, byteLength }; + return new BatchEntry(views, byteLength); +} + +/** + * Split a batch entry into consecutive entries that are each smaller than + * `limit` bytes, preserving chunk order. A single chunk of `limit` bytes or + * more forms an entry on its own. Returns `undefined` if no split is needed. + * @param {{ views: object[], byteLength: number }} entry + * @param {number} limit + * @returns {Array<{ views: object[], byteLength: number }>|undefined} + */ +function splitBatchEntry(entry, limit) { + const { views } = entry; + if (entry.byteLength < limit || views.length < 2) return undefined; + const entries = []; + let current = []; + let byteLength = 0; + for (let i = 0; i < views.length; i++) { + const view = views[i]; + if (current.length > 0 && byteLength + view.byteLength >= limit) { + entries[entries.length] = new BatchEntry(current, byteLength); + current = []; + byteLength = 0; + } + current[current.length] = view; + byteLength += view.byteLength; + } + entries[entries.length] = new BatchEntry(current, byteLength); + return entries; } function validateBatchEntry(entry) { @@ -300,6 +781,14 @@ function concatBytes(chunks) { return concatenated; } +// Conversion contexts for the per-write paths. The converters only read +// them (to build error messages), so they are shared rather than allocated +// on every write. +const kChunkContext = ObjectFreeze({ __proto__: null, context: 'chunk' }); +const kChunksContext = ObjectFreeze({ __proto__: null, context: 'chunks' }); +const kWriteOptionsContext = + ObjectFreeze({ __proto__: null, context: 'options' }); + /** * Convert an array of chunks (strings or Uint8Arrays) to a Uint8Array[]. * Always returns a fresh copy of the array. @@ -307,10 +796,7 @@ function concatBytes(chunks) { * @returns {Uint8Array[]} */ function convertChunks(chunks) { - chunks = converters.WriterChunkSequence(chunks, { - __proto__: null, - context: 'chunks', - }); + chunks = converters.WriterChunkSequence(chunks, kChunksContext); const len = chunks.length; const result = new Array(len); for (let i = 0; i < len; i++) { @@ -325,17 +811,14 @@ function convertChunks(chunks) { * @returns {AbortSignal|undefined} */ function getWriterSignal(options) { - return converters.WriteOptions(options, { - __proto__: null, - context: 'options', - }).signal; + // Writes are hot, and converting undefined or null creates an empty + // dictionary, which has no signal. + if (options === undefined || options === null) return undefined; + return converters.WriteOptions(options, kWriteOptionsContext).signal; } function toWriterUint8Array(chunk) { - return toUint8Array(converters.WriterChunk(chunk, { - __proto__: null, - context: 'chunk', - })); + return toUint8Array(converters.WriterChunk(chunk, kChunkContext)); } /** @@ -346,7 +829,10 @@ function toWriterUint8Array(chunk) { * @returns {boolean} */ function getProtocolMethod(value, symbol) { - if (value === null || typeof value !== 'object' || !(symbol in value)) { + // Functions are objects too, and may implement the protocols. + if (value === null || + (typeof value !== 'object' && typeof value !== 'function') || + !(symbol in value)) { return undefined; } const method = value[symbol]; @@ -450,12 +936,26 @@ function validateBackpressure(value) { } module.exports = { + IterResult, + kActive, + kDone, + kStart, + PendingRequest, + PendingWrite, kMultiConsumerDefaultBudget, + kNullOnceOption, kPushDefaultBudget, kResolvedPromise, + callWithByteView, concatBytes, + createOperationQueue, convertChunks, + checkFixedBatchChunk, createBatchEntry, + fixedBatchToEntry, + snapshotFixedBatch, + recordChunk, + splitBatchEntry, getProtocolMethod, getWriterSignal, getMinCursor, @@ -469,6 +969,8 @@ module.exports = { toWriterUint8Array, validateBackpressure, validateBatchEntry, + validateBudget, + validateRecordedChunks, validateByteView, yieldAbortable, }; diff --git a/lib/internal/streams/iter/webidl.js b/lib/internal/streams/iter/webidl.js index 273a52443bf2..6ee1afd622a5 100644 --- a/lib/internal/streams/iter/webidl.js +++ b/lib/internal/streams/iter/webidl.js @@ -27,17 +27,6 @@ function enforceRangeUnsignedLongLong(value, options = { __proto__: null }) { }); } -function allowStreamBufferOptions(options) { - return { - __proto__: null, - prefix: options.prefix, - context: options.context, - code: options.code, - allowShared: true, - allowResizable: true, - }; -} - converters.AbortSignal = baseConverters.AbortSignal; converters.BackpressurePolicy = createEnumConverter('BackpressurePolicy', [ 'strict', @@ -48,10 +37,11 @@ converters.BackpressurePolicy = createEnumConverter('BackpressurePolicy', [ converters.unsignedLongLong = unsignedLongLong; converters.enforceRangeUnsignedLongLong = enforceRangeUnsignedLongLong; converters.WriterChunk = (value, options = { __proto__: null }) => { - if (isUint8Array(value)) { - return baseConverters.Uint8Array( - value, allowStreamBufferOptions(options)); - } + // The chunk is a Uint8Array with [AllowShared] and [AllowResizable]. The + // Uint8Array conversion cannot reject a value isUint8Array() accepts when + // both are allowed, and returns the same object, so skip it: writes are + // hot, and the conversion would need its own options object. + if (isUint8Array(value)) return value; return baseConverters.USVString(value, options); }; converters.WriterChunkSequence = createSequenceConverter( @@ -104,6 +94,13 @@ converters.PipeToOptions = createDictionaryConverter('PipeToOptions', [ ]); converters.PipeToSyncOptions = createDictionaryConverter( 'PipeToSyncOptions', [ + // Node.js extension. + { + __proto__: null, + key: 'failOnIncompleteClose', + converter: baseConverters.boolean, + defaultValue: () => false, + }, { __proto__: null, key: 'preventClose', diff --git a/test/parallel/test-fs-promises-file-handle-pull.js b/test/parallel/test-fs-promises-file-handle-pull.js index cdfaf273f933..055a1e395298 100644 --- a/test/parallel/test-fs-promises-file-handle-pull.js +++ b/test/parallel/test-fs-promises-file-handle-pull.js @@ -148,17 +148,30 @@ async function testPullLocking() { const fh = await open(filePath, 'r'); try { - // First pull locks the handle + // The handle is locked once the first iterable starts being consumed. const readable = fh.pull(); + const other = fh.pull(); + const iter = readable[Symbol.asyncIterator](); + const first = await iter.next(); + assert.strictEqual(first.done, false); - // Second pull while locked should throw + // Consuming a second iterable while locked should fail, and so should + // creating new consumers. + await assert.rejects( + other[Symbol.asyncIterator]().next(), + { code: 'ERR_INVALID_STATE' }, + ); assert.throws( () => fh.pull(), { code: 'ERR_INVALID_STATE' }, ); + assert.throws( + () => fh.writer(), + { code: 'ERR_INVALID_STATE' }, + ); - // Consume the first stream to unlock - await text(readable); + // Finish consuming the first stream to unlock + while (!(await iter.next()).done); // Now it should be usable again const readable2 = fh.pull(); @@ -417,6 +430,36 @@ async function testPullSyncArgumentValidation() { } } +// ============================================================================= +// An iterable that is never consumed must not lock the handle +// ============================================================================= + +async function testPullUnconsumedDoesNotLock() { + const filePath = path.join(tmpDir, 'pull-unconsumed.txt'); + fs.writeFileSync(filePath, 'unconsumed'); + + const fh = await open(filePath, 'r'); + try { + fh.pull(); + fh.pull((chunks) => chunks); + assert.strictEqual(await text(fh.pull()), 'unconsumed'); + const w = fh.writer(); + assert.strictEqual(w.endSync(), 0); + } finally { + await fh.close(); + } +} + +async function testPullAfterCloseRejectsOnIteration() { + const filePath = path.join(tmpDir, 'pull-closed-before-iter.txt'); + fs.writeFileSync(filePath, 'data'); + + const fh = await open(filePath, 'r'); + const readable = fh.pull(); + await fh.close(); + await assert.rejects(text(readable), { code: 'ERR_INVALID_STATE' }); +} + Promise.all([ testBasicPull(), testPullBinary(), @@ -437,4 +480,6 @@ Promise.all([ testPullChunkSize(), testPullChunkSizeSmall(), testPullSyncArgumentValidation(), + testPullUnconsumedDoesNotLock(), + testPullAfterCloseRejectsOnIteration(), ]).then(common.mustCall()); diff --git a/test/parallel/test-fs-promises-file-handle-pullsync.js b/test/parallel/test-fs-promises-file-handle-pullsync.js index 20c429972573..82bb5d82393f 100644 --- a/test/parallel/test-fs-promises-file-handle-pullsync.js +++ b/test/parallel/test-fs-promises-file-handle-pullsync.js @@ -473,6 +473,31 @@ async function testPullArgumentValidation() { // Run all tests // ============================================================================= +// ============================================================================= +// An iterable that is never consumed must not lock the handle +// ============================================================================= + +async function testPullSyncUnconsumedDoesNotLock() { + const filePath = path.join(tmpDir, 'pullsync-unconsumed.txt'); + fs.writeFileSync(filePath, 'unconsumed'); + + const fh = await open(filePath, 'r'); + try { + fh.pullSync(); + // Returning an iterator that never started must not unlock or close + // the handle on behalf of another consumer. + fh.pullSync()[Symbol.iterator]().return(); + const iter = fh.pullSync()[Symbol.iterator](); + assert.strictEqual(iter.next().done, false); + assert.throws(() => fh.writer(), { code: 'ERR_INVALID_STATE' }); + iter.return(); + const w = fh.writer(); + assert.strictEqual(w.endSync(), 0); + } finally { + await fh.close(); + } +} + Promise.all([ testBasicPullSync(), testLargeFile(), @@ -495,4 +520,5 @@ Promise.all([ testPullSyncChunkSize(), testWriterChunkSize(), testPullArgumentValidation(), + testPullSyncUnconsumedDoesNotLock(), ]).then(common.mustCall()); diff --git a/test/parallel/test-fs-promises-file-handle-writer.js b/test/parallel/test-fs-promises-file-handle-writer.js index a636844f8e57..1074c8156aad 100644 --- a/test/parallel/test-fs-promises-file-handle-writer.js +++ b/test/parallel/test-fs-promises-file-handle-writer.js @@ -1133,6 +1133,69 @@ async function testWriterWebIDLConversion() { // Run all tests // ============================================================================= +// ============================================================================= +// Overlapping (un-awaited) writes must land in call order +// ============================================================================= + +function makeTestData(size) { + const data = Buffer.allocUnsafe(size); + let seed = 0x9e3779b9; + for (let i = 0; i < size; i++) { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + data[i] = seed & 0xff; + } + return data; +} + +async function testConcurrentWritesPreserveOrder() { + const data = makeTestData(512 * 1024); + for (const options of [{}, { start: 0 }]) { + for (const useWritev of [false, true]) { + const filePath = path.join( + tmpDir, + `writer-concurrent-${options.start ?? 'none'}-${useWritev}.bin`); + const fh = await open(filePath, 'w'); + const w = fh.writer(options); + const writes = []; + let offset = 0; + let i = 0; + while (offset < data.length) { + const size = 1 + ((i++ * 7919) % 9000); + const end = Math.min(offset + size, data.length); + const chunk = data.subarray(offset, end); + writes.push(useWritev ? + w.writev([chunk.subarray(0, 1), chunk.subarray(1)]) : + w.write(chunk)); + offset = end; + } + await Promise.all(writes); + assert.strictEqual(await w.end(), data.length); + await fh.close(); + assert.deepStrictEqual(fs.readFileSync(filePath), data); + } + } +} + +async function testFailStopsQueuedWrites() { + const filePath = path.join(tmpDir, 'writer-fail-queued.bin'); + const fh = await open(filePath, 'w'); + const w = fh.writer(); + const reason = new Error('stop'); + const writes = []; + for (let i = 0; i < 10; i++) { + writes.push(w.write(Buffer.alloc(1024, i))); + } + w.fail(reason); + const results = await Promise.allSettled(writes); + for (const result of results) { + assert.strictEqual(result.status, 'rejected'); + assert.strictEqual(result.reason, reason); + } + await fh.close(); + // None of the queued writes may reach the file after fail(). + assert.strictEqual(fs.statSync(filePath).size, 0); +} + Promise.all([ testBasicWrite(), testBasicWritev(), @@ -1187,4 +1250,6 @@ Promise.all([ testWriterLimitAndStart(), testWriterArgumentValidation(), testWriterWebIDLConversion(), + testConcurrentWritesPreserveOrder(), + testFailStopsQueuedWrites(), ]).then(common.mustCall()); diff --git a/test/parallel/test-quic-stream-writer-api.mjs b/test/parallel/test-quic-stream-writer-api.mjs index 6ccd52b046a4..2b9184d68df5 100644 --- a/test/parallel/test-quic-stream-writer-api.mjs +++ b/test/parallel/test-quic-stream-writer-api.mjs @@ -103,6 +103,11 @@ await clientSession.opened; { code: 'ERR_INVALID_ARG_TYPE' }, ); assert.strictEqual(w.endSync(), 12); + // Writes after the writer is closed reject with a TypeError. + await assert.rejects(w.write('closed'), + { code: 'ERR_INVALID_STATE', name: 'TypeError' }); + await assert.rejects(w.writev(['closed']), + { code: 'ERR_INVALID_STATE', name: 'TypeError' }); for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars await stream.closed; } diff --git a/test/parallel/test-stream-iter-broadcast-basic.js b/test/parallel/test-stream-iter-broadcast-basic.js index 4384c2b0d6d6..0e78d81828ab 100644 --- a/test/parallel/test-stream-iter-broadcast-basic.js +++ b/test/parallel/test-stream-iter-broadcast-basic.js @@ -386,16 +386,34 @@ async function testOverlappingNextKeepsEarlierRead() { assert.strictEqual(Buffer.concat(result.value).toString(), 'x'); writer.endSync(); - assert.deepStrictEqual(await second, { - __proto__: null, + assert.deepStrictEqual({ ...await second }, { done: true, value: undefined, }); assert.strictEqual(bc.consumerCount, 0); } +async function testOverlappingNextResolvedInOrder() { + const { writer, broadcast: bc } = broadcast(); + const it = bc.push()[Symbol.asyncIterator](); + const reads = [it.next(), it.next(), it.next(), it.next(), it.next()]; + + for (const value of ['a', 'b', 'c']) await writer.write(value); + const error = new Error('failed'); + writer.fail(error); + + for (const [i, value] of ['a', 'b', 'c'].entries()) { + const result = await reads[i]; + assert.strictEqual(result.done, false); + assert.strictEqual(Buffer.concat(result.value).toString(), value); + } + await assert.rejects(reads[3], error); + await assert.rejects(reads[4], error); +} + Promise.all([ testBasicBroadcast(), + testOverlappingNextResolvedInOrder(), testMultipleWrites(), testConsumerCount(), testWriteSync(), diff --git a/test/parallel/test-stream-iter-broadcast-from.js b/test/parallel/test-stream-iter-broadcast-from.js index cd76f3647b2b..b1918ea95b9c 100644 --- a/test/parallel/test-stream-iter-broadcast-from.js +++ b/test/parallel/test-stream-iter-broadcast-from.js @@ -144,8 +144,7 @@ async function testBroadcastFromCancelWhileBlocked() { let writesAfterCancel = 0; writer.writevSync = () => { writesAfterCancel++; return true; }; bc.cancel(); - assert.deepStrictEqual(await pendingRead, { - __proto__: null, + assert.deepStrictEqual({ ...await pendingRead }, { done: true, value: undefined, }); diff --git a/test/parallel/test-stream-iter-from-async.js b/test/parallel/test-stream-iter-from-async.js index 29ecdf425767..356f07353d72 100644 --- a/test/parallel/test-stream-iter-from-async.js +++ b/test/parallel/test-stream-iter-from-async.js @@ -99,12 +99,61 @@ async function testFromBoundsNestedAsyncIterable() { const iterator = from(source())[Symbol.asyncIterator](); const first = await iterator.next(); assert.strictEqual(first.done, false); - assert.strictEqual(first.value.length, 128); + assert.ok(first.value.length > 0); + assert.ok(first.value.length <= 128); await iterator.return(); assert.strictEqual(nestedClosed, true); } +async function testFromDoesNotHoldBackNestedAsyncIterable() { + // Chunks from a nested async iterable must be delivered as they become + // available, not held back until the nested iterable produces more data + // or ends. + const { promise: release, resolve } = Promise.withResolvers(); + async function* nested() { + yield new Uint8Array([1]); + await release; + yield new Uint8Array([2]); + } + + async function* source() { + yield nested(); + yield [new Uint8Array([3]), Promise.resolve(new Uint8Array([4]))]; + } + + const iterator = from(source())[Symbol.asyncIterator](); + assert.deepStrictEqual({ ...await iterator.next() }, + { done: false, value: [new Uint8Array([1])] }); + resolve(); + const rest = []; + for (let r = await iterator.next(); !r.done; r = await iterator.next()) { + for (const chunk of r.value) rest.push(chunk[0]); + } + assert.deepStrictEqual(rest, [2, 3, 4]); +} + +async function testFromBoundsPreBatchedAsyncValues() { + // An async source yielding an already-batched Uint8Array[] larger than the + // batch bound is split, like the same batch from a sync source. + const big = Array.from({ length: 300 }, (_, i) => new Uint8Array([i & 0xff])); + async function* source() { + yield big; + } + const sizes = []; + for await (const batch of from(source())) sizes.push(batch.length); + assert.deepStrictEqual(sizes, [128, 128, 44]); + + // Batches within the bound are still passed through as-is. + const small = [new Uint8Array([1]), new Uint8Array([2])]; + async function* smallSource() { + yield small; + } + for await (const batch of from(smallSource())) { + assert.strictEqual(batch, small); + } +} + async function testFromSyncIterableAsAsync() { // Sync iterable passed to from() should work function* gen() { @@ -458,12 +507,309 @@ function testFromUndefinedThrows() { assert.throws(() => from(undefined), { code: 'ERR_INVALID_ARG_TYPE' }); } +async function testFromFunctionWithProtocols() { + // Functions are objects and may implement the protocols. + function asyncSource() {} + asyncSource[Symbol.for('Stream.toAsyncStreamable')] = + async () => 'async-function'; + assert.strictEqual(await text(from(asyncSource)), 'async-function'); + + function syncSource() {} + syncSource[Symbol.for('Stream.toStreamable')] = () => 'sync-function'; + assert.strictEqual(await text(from(syncSource)), 'sync-function'); + + async function* nested() { + yield asyncSource; + yield syncSource; + } + assert.strictEqual(await text(from(nested())), + 'async-functionsync-function'); + + // A function without a protocol is still rejected. + assert.throws(() => from(() => {}), { code: 'ERR_INVALID_ARG_TYPE' }); +} + +// from() reads async sources like an async generator looping over them with +// for await. The tests below check the parts of that behavior that are +// observable from the source: when it is read and closed. + +// Creates an async iterable source of `values`, recording calls in `log`. +function createLoggedSource(log, values, { failAt = -1, returnError } = {}) { + let i = 0; + return { + [Symbol.asyncIterator]() { + log.push('iterator'); + return { + async next() { + log.push(`next ${i}`); + if (i === failAt) { + i++; + throw new Error('source failed'); + } + if (i >= values.length) return { done: true, value: undefined }; + return { done: false, value: values[i++] }; + }, + async return() { + log.push('return'); + if (returnError !== undefined) throw returnError; + return { done: true, value: undefined }; + }, + }; + }, + }; +} + +async function settle(log, label, promise) { + try { + const result = await promise; + log.push(`${label}: ${result.done ? 'done' : result.value.length}`); + } catch (error) { + const reason = error.name === 'AbortError' ? error.name : + error.code ?? error.message; + log.push(`${label}: ${reason}`); + } +} + +async function testFromQueuesConcurrentNext() { + const log = []; + const iterator = from(createLoggedSource(log, [ + Uint8Array.of(1), Uint8Array.of(2), + ]))[Symbol.asyncIterator](); + const results = [iterator.next(), iterator.next(), iterator.next()]; + for (let i = 0; i < results.length; i++) { + await settle(log, `result ${i}`, results[i]); + } + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'next 1', 'result 0: 1', 'next 2', 'result 1: 1', + 'result 2: done', + ]); +} + +async function testFromSplitsOversizedBatches() { + const log = []; + const batch = Array.from({ length: 300 }, () => new Uint8Array(1)); + const iterator = from(createLoggedSource(log, [batch, [], [batch[0]]]))[ + Symbol.asyncIterator](); + for (let i = 0; i < 5; i++) await settle(log, `result ${i}`, iterator.next()); + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'result 0: 128', 'result 1: 128', 'result 2: 44', + 'next 1', 'next 2', 'result 3: 1', 'next 3', 'result 4: done', + ]); +} + +async function testFromSourceErrorDoesNotCloseSource() { + const log = []; + const iterator = from(createLoggedSource(log, [Uint8Array.of(1)], { + failAt: 1, + }))[Symbol.asyncIterator](); + for (let i = 0; i < 3; i++) await settle(log, `result ${i}`, iterator.next()); + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'result 0: 1', 'next 1', 'result 1: source failed', + 'result 2: done', + ]); +} + +async function testFromNormalizationErrorClosesSource() { + // A value that cannot be normalized. + let log = []; + let iterator = from(createLoggedSource(log, [Uint8Array.of(1), 42]))[ + Symbol.asyncIterator](); + for (let i = 0; i < 3; i++) await settle(log, `result ${i}`, iterator.next()); + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'result 0: 1', 'next 1', 'return', + 'result 1: ERR_INVALID_ARG_TYPE', 'result 2: done', + ]); + + // A nested async iterable that fails. + async function* nested() { + yield Uint8Array.of(2); + throw new Error('nested failed'); + } + log = []; + iterator = from(createLoggedSource(log, [nested()]))[Symbol.asyncIterator](); + for (let i = 0; i < 3; i++) await settle(log, `result ${i}`, iterator.next()); + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'result 0: 1', 'return', 'result 1: nested failed', + 'result 2: done', + ]); +} + +async function testFromReturnAndThrowBeforeStart() { + const log = []; + let iterator = from(createLoggedSource(log, [Uint8Array.of(1)]))[ + Symbol.asyncIterator](); + assert.deepStrictEqual({ ...await iterator.return('v') }, + { done: true, value: 'v' }); + await settle(log, 'after return', iterator.next()); + iterator = from(createLoggedSource(log, [Uint8Array.of(1)]))[ + Symbol.asyncIterator](); + await settle(log, 'throw', iterator.throw(new Error('thrown'))); + await settle(log, 'after throw', iterator.next()); + // The source is never read. + assert.deepStrictEqual(log, [ + 'after return: done', 'throw: thrown', 'after throw: done', + ]); +} + +async function testFromReturnAndThrowCloseSource() { + const returnError = new Error('return failed'); + for (const method of ['return', 'throw']) { + const log = []; + let nestedClosed = false; + async function* nested() { + try { + yield Uint8Array.of(1); + yield Uint8Array.of(2); + } finally { + nestedClosed = true; + } + } + const iterator = from(createLoggedSource(log, [nested()], { + returnError, + }))[Symbol.asyncIterator](); + await settle(log, 'result', iterator.next()); + await settle(log, method, iterator[method](new Error('thrown'))); + await settle(log, 'after', iterator.next()); + assert.strictEqual(nestedClosed, true); + // return() propagates errors from closing the source; throw() keeps its + // own error. + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'result: 1', 'return', + method === 'return' ? 'return: return failed' : 'throw: thrown', + 'after: done', + ]); + } +} + +// Creates a sync iterable source of `values`, recording calls in `log`. +function createLoggedSyncSource(log, values, { failAt = -1, returnError } = {}) { + let i = 0; + return { + [Symbol.iterator]() { + log.push('iterator'); + return { + next() { + log.push(`next ${i}`); + if (i === failAt) { + i++; + throw new Error('source failed'); + } + if (i >= values.length) return { done: true, value: undefined }; + return { done: false, value: values[i++] }; + }, + return() { + log.push('return'); + if (returnError !== undefined) throw returnError; + return { done: true, value: undefined }; + }, + }; + }, + }; +} + +async function testFromSyncSourceBatching() { + // Single chunks are collected into batches of up to 128 chunks; any other + // value is yielded after the chunks collected before it. + const log = []; + const chunks = Array.from({ length: 130 }, () => new Uint8Array(1)); + const iterator = from(createLoggedSyncSource(log, [ + ...chunks, [Uint8Array.of(1), Uint8Array.of(2)], Uint8Array.of(3), 'ab', + ]))[Symbol.asyncIterator](); + for (let i = 0; i < 6; i++) await settle(log, `result ${i}`, iterator.next()); + assert.deepStrictEqual(log, [ + 'iterator', ...Array.from({ length: 128 }, (_, i) => `next ${i}`), + 'result 0: 128', 'next 128', 'next 129', 'next 130', 'result 1: 2', + 'result 2: 2', 'next 131', 'next 132', 'result 3: 1', 'result 4: 1', + 'next 133', 'result 5: done', + ]); +} + +async function testFromSyncSourceErrors() { + // An error from the source does not close it. + let log = []; + let iterator = from(createLoggedSyncSource(log, [[Uint8Array.of(1)]], { + failAt: 1, + }))[Symbol.asyncIterator](); + for (let i = 0; i < 3; i++) await settle(log, `result ${i}`, iterator.next()); + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'result 0: 1', 'next 1', 'result 1: source failed', + 'result 2: done', + ]); + + // An error processing a value closes it, also after flushing the chunks + // collected before the value, and when normalizing a promise fails. + for (const value of [42, Promise.reject(new Error('rejected'))]) { + log = []; + iterator = from(createLoggedSyncSource(log, [Uint8Array.of(1), value]))[ + Symbol.asyncIterator](); + for (let i = 0; i < 3; i++) { + await settle(log, `result ${i}`, iterator.next()); + } + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'next 1', 'result 0: 1', 'return', + `result 1: ${value === 42 ? 'ERR_INVALID_ARG_TYPE' : 'rejected'}`, + 'result 2: done', + ]); + } +} + +async function testFromSyncSourceReturnAndThrow() { + const returnError = new Error('return failed'); + for (const method of ['return', 'throw']) { + // While reading the source, it is closed: return() propagates errors + // from closing it, throw() keeps its own error. + let log = []; + let iterator = from(createLoggedSyncSource(log, [ + [Uint8Array.of(1)], [Uint8Array.of(2)], + ], { returnError }))[Symbol.asyncIterator](); + await settle(log, 'result', iterator.next()); + await settle(log, method, iterator[method](new Error('thrown'))); + await settle(log, 'after', iterator.next()); + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'result: 1', 'return', + method === 'return' ? 'return: return failed' : 'throw: thrown', + 'after: done', + ]); + + // Once the source has ended, it is not closed. + log = []; + iterator = from(createLoggedSyncSource(log, [Uint8Array.of(1)]))[ + Symbol.asyncIterator](); + await settle(log, 'result', iterator.next()); + await settle(log, method, iterator[method](new Error('thrown'))); + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'next 1', 'result: 1', + method === 'return' ? 'return: done' : 'throw: thrown', + ]); + } +} + +async function testFromSyncSourceQueuesBehindReturn() { + // A next() made synchronously after another waits for it, and so sees a + // return() made synchronously after it as well. + const log = []; + const iterator = from(createLoggedSyncSource(log, [ + [Uint8Array.of(1)], 'a', [Uint8Array.of(2)], + ]))[Symbol.asyncIterator](); + const results = [iterator.next(), iterator.next(), iterator.return()]; + for (let i = 0; i < results.length; i++) { + await settle(log, `result ${i}`, results[i]); + } + assert.deepStrictEqual(log, [ + 'iterator', 'next 0', 'next 1', 'return', 'result 0: 1', + 'result 1: AbortError', 'result 2: done', + ]); +} + Promise.all([ testFromString(), testFromAsyncGenerator(), testFromAsyncIteratorResultShapes(), testFromSourceErrorDoesNotWaitForReturn(), testFromBoundsNestedAsyncIterable(), + testFromFunctionWithProtocols(), + testFromDoesNotHoldBackNestedAsyncIterable(), + testFromBoundsPreBatchedAsyncValues(), testFromSyncIterableAsAsync(), testFromSyncIterableAwaitsPromiseValues(), testFromSyncIterableRejectsNestedAsyncIterable(), @@ -489,4 +835,14 @@ Promise.all([ testConsumerAbortClosesPendingNestedIterator(), testFromCancellationHandlesCleanupRejection(), testFromDataView(), + testFromQueuesConcurrentNext(), + testFromSplitsOversizedBatches(), + testFromSourceErrorDoesNotCloseSource(), + testFromNormalizationErrorClosesSource(), + testFromReturnAndThrowBeforeStart(), + testFromReturnAndThrowCloseSource(), + testFromSyncSourceBatching(), + testFromSyncSourceErrors(), + testFromSyncSourceReturnAndThrow(), + testFromSyncSourceQueuesBehindReturn(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-from-sync.js b/test/parallel/test-stream-iter-from-sync.js index 50d85dc94a98..133597914c45 100644 --- a/test/parallel/test-stream-iter-from-sync.js +++ b/test/parallel/test-stream-iter-from-sync.js @@ -183,7 +183,10 @@ function testFromSyncIgnoresAsyncStreamable() { // Explicit async iterable rejected function testFromSyncRejectsAsyncIterable() { async function* gen() { yield [new TextEncoder().encode('a')]; } - assert.throws(() => fromSync(gen()), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => fromSync(gen()), { + code: 'ERR_INVALID_ARG_TYPE', + message: /must be a synchronous input, not an async iterable\./, + }); } function testFromSyncPrefersIteratorForDualIterable() { @@ -212,8 +215,10 @@ function testFromSyncPrefersIteratorForThenableIterable() { // Promise rejected function testFromSyncRejectsPromise() { - assert.throws(() => fromSync(Promise.resolve('hello')), - { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => fromSync(Promise.resolve('hello')), { + code: 'ERR_INVALID_ARG_TYPE', + message: /must be a synchronous input, not a promise\./, + }); } // DataView input should be converted to Uint8Array (zero-copy) @@ -239,6 +244,17 @@ function testFromSyncUndefinedThrows() { assert.throws(() => fromSync(undefined), { code: 'ERR_INVALID_ARG_TYPE' }); } +function testFromSyncFunctionWithToStreamable() { + // Functions are objects and may implement the protocol. + function source() {} + source[Symbol.for('Stream.toStreamable')] = () => 'from-function'; + assert.strictEqual(textSync(fromSync(source)), 'from-function'); + // ...also when nested inside another source. + assert.strictEqual(textSync(fromSync([source, '!'])), 'from-function!'); + // A function without a protocol is still rejected. + assert.throws(() => fromSync(() => {}), { code: 'ERR_INVALID_ARG_TYPE' }); +} + Promise.all([ testFromSyncString(), testFromSyncUint8Array(), @@ -260,4 +276,5 @@ Promise.all([ testFromSyncPrefersIteratorForThenableIterable(), testFromSyncRejectsPromise(), testFromSyncDataView(), + testFromSyncFunctionWithToStreamable(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-from-writable-lifecycle.js b/test/parallel/test-stream-iter-from-writable-lifecycle.js index cf6732c4184f..450fe8d7359c 100644 --- a/test/parallel/test-stream-iter-from-writable-lifecycle.js +++ b/test/parallel/test-stream-iter-from-writable-lifecycle.js @@ -202,7 +202,7 @@ async function testAlreadyFinished() { const writer = fromWritable(writable); assert.strictEqual(writer.canWrite, null); await assert.rejects(writer.write('a'), - { code: 'ERR_STREAM_WRITE_AFTER_END' }); + { code: 'ERR_STREAM_WRITE_AFTER_END', name: 'TypeError' }); assert.strictEqual(await writer.end(), 0); assertNoTerminalListeners(writable); } @@ -215,7 +215,7 @@ async function testAlreadyDestroyed() { const writer = fromWritable(writable); assert.strictEqual(writer.canWrite, null); await assert.rejects(writer.write('a'), - { code: 'ERR_STREAM_WRITE_AFTER_END' }); + { code: 'ERR_STREAM_WRITE_AFTER_END', name: 'TypeError' }); assertNoTerminalListeners(writable); } @@ -277,7 +277,40 @@ async function testSignalAbortedByUnderlyingEnd() { assert.strictEqual(await ending, 0); } +// A write that fills the Writable resolves before 'drain'. If the Writable's +// write callback runs on a microtask or a tick, 'drain' can be emitted before +// the next write or wait; the adapter must not miss it. +async function testDrainBeforeNextWrite() { + for (const defer of [queueMicrotask, process.nextTick]) { + const writable = new Writable({ + highWaterMark: 4, + write(chunk, encoding, callback) { defer(callback); }, + }); + const writer = fromWritable(writable); + + for (let i = 0; i < 3; i++) { + await writer.write('abcd'); + } + await writer.writev(['ab', 'cd']); + await writer.writev(['ab', 'cd']); + + await setImmediate(); + assert.strictEqual(writer.canWrite, true); + // Nothing is waiting any more, so the 'drain' listener is removed. + assert.strictEqual(writable.listenerCount('drain'), 0); + + writer.write('abcd'); + assert.strictEqual(writer.canWrite, false); + assert.strictEqual(await ondrain(writer), true); + assert.strictEqual(writer.canWrite, true); + + await writer.end(); + assert.strictEqual(writable.listenerCount('drain'), 0); + } +} + Promise.all([ + testDrainBeforeNextWrite(), testQueuedWriteThrowsDuringFlush(), testQueuedWriteErrorsDuringFlush(), testWriteErrorsSynchronously(), diff --git a/test/parallel/test-stream-iter-iterator-result.js b/test/parallel/test-stream-iter-iterator-result.js new file mode 100644 index 000000000000..96cbdc6ec18d --- /dev/null +++ b/test/parallel/test-stream-iter-iterator-result.js @@ -0,0 +1,86 @@ +// Flags: --experimental-stream-iter +'use strict'; + +// Iterator results created by the stream/iter iterators themselves do not +// inherit from Object.prototype, so prototype pollution cannot affect them. + +const common = require('../common'); +const assert = require('assert'); +const { inspect } = require('util'); +const { + broadcast, + from, + pull, + push, + share, + shareSync, +} = require('stream/iter'); + +function assertResult(result, done) { + assert.strictEqual(result instanceof Object, false); + assert.deepStrictEqual(Object.keys(result), ['done', 'value']); + assert.strictEqual(result.done, done); + assert.match(inspect(result), /^IterResult \{ done: (true|false), value: /); +} + +async function testAsyncIterators() { + const sources = { + 'push()': () => { + const { writer, readable } = push(); + writer.writeSync('a'); + writer.endSync(); + return readable; + }, + 'share()': () => share(from('a')).pull(), + 'pull()': () => pull(from('a')), + 'pull() with a signal': () => pull(from('a'), { + signal: new AbortController().signal, + }), + 'pull() with a transform': () => pull(from('a'), (chunks) => chunks), + 'from() of an async iterable': () => from((async function*() { + yield 'a'; + })()), + 'broadcast()': () => { + const { writer, broadcast: bc } = broadcast(); + const consumer = bc.push(); + writer.writeSync('a'); + writer.endSync(); + return consumer; + }, + }; + for (const create of Object.values(sources)) { + const iterator = create()[Symbol.asyncIterator](); + assertResult(await iterator.next(), false); + assertResult(await iterator.next(), true); + } +} + +function testShareSync() { + const iterator = shareSync(['a']).pull()[Symbol.iterator](); + assertResult(iterator.next(), false); + assertResult(iterator.next(), true); +} + +async function testPollutedThen() { + // Resolving an async next() with an object looks up `then`. Results must + // not pick it up from a polluted Object.prototype. + const { writer, readable } = push(); + writer.writeSync('ab'); + writer.endSync(); + const iterator = readable[Symbol.asyncIterator](); + Object.prototype.then = common.mustNotCall('Object.prototype.then'); + try { + const first = await iterator.next(); + assert.strictEqual(first.done, false); + assert.strictEqual(new TextDecoder().decode(first.value[0]), 'ab'); + assert.strictEqual((await iterator.next()).done, true); + } finally { + delete Object.prototype.then; + } +} + +(async () => { + await testAsyncIterators(); + testShareSync(); + await testPollutedThen(); +})().then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-pipeto-edge.js b/test/parallel/test-stream-iter-pipeto-edge.js index 13223a226a1f..082871a9e4c1 100644 --- a/test/parallel/test-stream-iter-pipeto-edge.js +++ b/test/parallel/test-stream-iter-pipeto-edge.js @@ -5,7 +5,9 @@ const common = require('../common'); const assert = require('assert'); -const { pipeToSync, fromSync } = require('stream/iter'); +const { + pipeTo, pipeToSync, fromSync, push, text, +} = require('stream/iter'); // pipeToSync cannot complete when endSync() requires async fallback. async function testPipeToSyncEndSyncFailure() { @@ -22,20 +24,41 @@ async function testPipeToSyncEndSyncFailure() { assert.strictEqual(endCalled, false); } -// pipeToSync requires endSync() when closing is enabled. +// The data was accepted, so endSync() returning -1 does not fail the writer, +// even without preventFail, and the caller can still close it. +async function testPipeToSyncEndSyncFailureDoesNotFailWriter() { + const writer = { + writeSync() { return true; }, + endSync: common.mustCall(() => -1), + end: common.mustNotCall(), + fail: common.mustNotCall(), + }; + assert.throws(() => pipeToSync(fromSync('data'), writer), + { code: 'ERR_INVALID_STATE' }); + + // A push() writer whose consumer has not read yet cannot close + // synchronously. After the throw, the data is intact and the writer can be + // ended asynchronously. + const { writer: pushWriter, readable } = push(); + assert.throws(() => pipeToSync(fromSync(['abc', 'def']), pushWriter), + { code: 'ERR_INVALID_STATE' }); + const result = text(readable); + assert.strictEqual(await pushWriter.end(), 6); + assert.strictEqual(await result, 'abcdef'); +} + +// pipeToSync does not require endSync(). async function testPipeToSyncNoEndSync() { - let writeCalled = false; - let endCalled = false; + // endSync() is optional. Without it the data is still written and the + // writer is not closed; pipeToSync() never falls back to end(). + const written = []; const writer = { - writeSync() { writeCalled = true; return true; }, - end() { endCalled = true; }, + writeSync(chunk) { written.push(chunk); return true; }, + end: common.mustNotCall(), + fail: common.mustNotCall(), }; - assert.throws( - () => pipeToSync(fromSync('data'), writer), - { code: 'ERR_INVALID_ARG_TYPE' }, - ); - assert.strictEqual(writeCalled, false); - assert.strictEqual(endCalled, false); + assert.strictEqual(pipeToSync(fromSync('data'), writer), 4); + assert.deepStrictEqual(written, [new TextEncoder().encode('data')]); } // pipeToSync with preventFail: true — source error does NOT call fail() @@ -68,9 +91,95 @@ async function testPipeToSyncPreventClose() { assert.strictEqual(endCalled, false); } +// An exception thrown by writer.fail() must not replace the error that made +// the pipe fail. +async function testFailThrowingDoesNotMaskError() { + const cause = new Error('write failed'); + const syncWriter = { + writeSync() { throw cause; }, + endSync: common.mustNotCall(), + fail: common.mustCall((error) => { + assert.strictEqual(error, cause); + throw new Error('fail() threw'); + }), + }; + assert.throws(() => pipeToSync(fromSync('data'), syncWriter), + (error) => error === cause); + + const asyncWriter = { + async write() { throw cause; }, + end: common.mustNotCall(), + fail: common.mustCall((error) => { + assert.strictEqual(error, cause); + throw new Error('fail() threw'); + }), + }; + await assert.rejects(pipeTo(fromSync('data'), asyncWriter), + (error) => error === cause); + + // Same for the already-aborted signal path of pipeTo(). + const signal = AbortSignal.abort(); + await assert.rejects( + pipeTo(fromSync('data'), { + write: common.mustNotCall(), + fail() { throw new Error('fail() threw'); }, + }, { signal }), + (error) => error === signal.reason); +} + +// failOnIncompleteClose fails a writer that cannot be closed synchronously, +// e.g. a sync-only writer that has no end(). +async function testPipeToSyncFailOnIncompleteClose() { + let failReason; + const writer = { + writeSync() { return true; }, + endSync: common.mustCall(() => -1), + fail: common.mustCall((reason) => { failReason = reason; }), + }; + assert.throws( + () => pipeToSync(fromSync('data'), writer, { failOnIncompleteClose: true }), + (error) => { + assert.strictEqual(error.code, 'ERR_INVALID_STATE'); + assert.strictEqual(error, failReason); + return true; + }); + + // preventFail takes precedence. + assert.throws( + () => pipeToSync(fromSync('data'), { + writeSync() { return true; }, + endSync: common.mustCall(() => -1), + fail: common.mustNotCall(), + }, { failOnIncompleteClose: true, preventFail: true }), + { code: 'ERR_INVALID_STATE' }); + + // It has no effect when the writer closes synchronously. + assert.strictEqual(pipeToSync(fromSync('data'), { + writeSync() { return true; }, + endSync: common.mustCall(() => 4), + fail: common.mustNotCall(), + }, { failOnIncompleteClose: true }), 4); + + // A push() writer is failed with the error, so its consumer sees it. + const { writer: pushWriter, readable } = push(); + let thrown; + assert.throws(() => { + try { + pipeToSync(fromSync('abc'), pushWriter, { failOnIncompleteClose: true }); + } catch (error) { + thrown = error; + throw error; + } + }, { code: 'ERR_INVALID_STATE' }); + await assert.rejects(text(readable), (error) => error === thrown); +} + Promise.all([ testPipeToSyncEndSyncFailure(), + testPipeToSyncEndSyncFailureDoesNotFailWriter(), + testPipeToSyncFailOnIncompleteClose(), testPipeToSyncNoEndSync(), testPipeToSyncPreventFail(), testPipeToSyncPreventClose(), + testFailThrowingDoesNotMaskError(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-pipeto-signal.js b/test/parallel/test-stream-iter-pipeto-signal.js index be04c8c635df..3645db1a92f4 100644 --- a/test/parallel/test-stream-iter-pipeto-signal.js +++ b/test/parallel/test-stream-iter-pipeto-signal.js @@ -7,7 +7,8 @@ const common = require('../common'); const assert = require('assert'); const { setTimeout } = require('timers/promises'); -const { pipeTo, from } = require('stream/iter'); +const { getEventListeners } = require('events'); +const { bytes, pipeTo, from } = require('stream/iter'); async function testPipeToPreAbortedSignalFailsWriter() { const reason = new Error('already aborted'); @@ -160,6 +161,57 @@ async function testPipeToLiveSignalWithTransformsCompletes() { assert.ok(written.length > 0); } +async function testSignalAbortedWhileReadingSource() { + // The signal can abort while the source is producing a value; the read + // must still reject with the abort reason, and the source be closed, even + // if the value never comes. + for (const consume of [ + (source, signal) => pipeTo(source, { write() {} }, { signal }), + (source, signal) => bytes(source, { signal }), + ]) { + const ac = new AbortController(); + const reason = new Error('aborted while reading'); + let closed = false; + const source = { + [Symbol.asyncIterator]() { + return { + next() { + ac.abort(reason); + return new Promise(() => {}); + }, + async return() { + closed = true; + return { done: true }; + }, + }; + }, + }; + await assert.rejects(consume(source, ac.signal), reason); + assert.strictEqual(closed, true); + } +} + +async function testSignalListenersRemoved() { + // No abort listener is left on the signal once reading completes, fails + // or is aborted. + const ac = new AbortController(); + await pipeTo(from('abc'), { write() {} }, { signal: ac.signal }); + await bytes(from('abc'), { signal: ac.signal }); + await assert.rejects(bytes((async function*() { + yield 'a'; + throw new Error('source failed'); + })(), { signal: ac.signal }), /source failed/); + assert.strictEqual(getEventListeners(ac.signal, 'abort').length, 0); + + const aborting = new AbortController(); + await assert.rejects(bytes((async function*() { + yield 'a'; + aborting.abort(); + yield 'b'; + })(), { signal: aborting.signal }), { name: 'AbortError' }); + assert.strictEqual(getEventListeners(aborting.signal, 'abort').length, 0); +} + Promise.all([ testPipeToPreAbortedSignalFailsWriter(), testPipeToPreAbortedSignalPreventFail(), @@ -168,4 +220,6 @@ Promise.all([ testPipeToLiveSignalWithTransforms(), testPipeToLiveSignalCompletes(), testPipeToLiveSignalWithTransformsCompletes(), + testSignalAbortedWhileReadingSource(), + testSignalListenersRemoved(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-pipeto.js b/test/parallel/test-stream-iter-pipeto.js index ed1614552f93..f1db05d0e6e8 100644 --- a/test/parallel/test-stream-iter-pipeto.js +++ b/test/parallel/test-stream-iter-pipeto.js @@ -345,6 +345,52 @@ async function testPipeToSyncIterableAsyncValue() { assert.strictEqual(result, 'ab'); } +// pipeTo() reads sync iterables synchronously when it can. An error writing +// a batch must still close the source, ignoring an error closing it, and an +// error reading the source must not close it. +async function testPipeToSyncIterableWriteError() { + for (const returnThrows of [false, true]) { + const error = new Error('write'); + let closed = false; + const source = { + [Symbol.iterator]() { + let i = 0; + return { + next() { + return { done: false, value: [new Uint8Array([i++])] }; + }, + return() { + closed = true; + if (returnThrows) throw new Error('return'); + return { done: true }; + }, + }; + }, + }; + await assert.rejects(pipeTo(source, { + write: common.mustNotCall(), + writeSync: common.mustCall(() => { throw error; }), + fail: common.mustCall((reason) => assert.strictEqual(reason, error)), + }), error); + assert.strictEqual(closed, true); + } + + const error = new Error('source'); + const source = { + [Symbol.iterator]() { + return { + next() { throw error; }, + return: common.mustNotCall(), + }; + }, + }; + await assert.rejects(pipeTo(source, { + write: common.mustNotCall(), + writeSync: common.mustNotCall(), + fail: common.mustCall((reason) => assert.strictEqual(reason, error)), + }), error); +} + Promise.all([ testPipeToSync(), testPipeTo(), @@ -365,5 +411,6 @@ Promise.all([ testPipeToSyncIterableUsesFromBatching(), testPipeToSyncIterableWriteFallback(), testPipeToSyncIterableAsyncValue(), + testPipeToSyncIterableWriteError(), testPipeToSourceNormalizationIndependentOfWriter(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-pull-async.js b/test/parallel/test-stream-iter-pull-async.js index 452ff3b17348..76e318dfa1a6 100644 --- a/test/parallel/test-stream-iter-pull-async.js +++ b/test/parallel/test-stream-iter-pull-async.js @@ -6,6 +6,7 @@ const assert = require('assert'); const { broadcast, from, + pipeTo, pull, push, share, @@ -15,6 +16,7 @@ const { } = require('stream/iter'); const { setImmediate } = require('timers/promises'); +const { inspect } = require('util'); async function testPullIdentity() { const data = await text(pull(from('hello-async'))); @@ -65,14 +67,72 @@ async function testPullStatefulTransformReceiver() { } async function testPullWithAbortSignal() { + let started = false; async function* gen() { + started = true; yield [new Uint8Array([1])]; } - assert.throws( - () => pull(gen(), { signal: AbortSignal.abort() }), - { name: 'AbortError' }, - ); + // An already-aborted signal does not make pull() throw; the returned + // iterable rejects instead, without reading from the source. + const signal = AbortSignal.abort(); + const iterator = pull(gen(), { signal })[Symbol.asyncIterator](); + await assert.rejects(iterator.next(), (error) => error === signal.reason); + await assert.rejects(iterator.next(), (error) => error === signal.reason); + assert.strictEqual(started, false); + assert.deepStrictEqual({ ...await iterator.return() }, + { done: true, value: undefined }); + + await assert.rejects(text(pull(gen(), (chunks) => chunks, { signal })), + (error) => error === signal.reason); + assert.strictEqual(started, false); +} + +async function testPullKeepsRejectingAfterAbort() { + for (const transforms of [[], [(chunks) => chunks]]) { + // Abort while a read is pending. + { + const ac = new AbortController(); + const reason = new Error('stop'); + async function* gen() { + yield [new Uint8Array([1])]; + await new Promise(() => {}); + } + const iterator = + pull(gen(), ...transforms, { signal: ac.signal })[Symbol.asyncIterator](); + assert.strictEqual((await iterator.next()).done, false); + const pending = iterator.next(); + ac.abort(reason); + await assert.rejects(pending, (error) => error === reason); + await assert.rejects(iterator.next(), (error) => error === reason); + await assert.rejects(iterator.next(), (error) => error === reason); + } + // Abort between reads. + { + const ac = new AbortController(); + const reason = new Error('stop'); + async function* gen() { + yield [new Uint8Array([1])]; + yield [new Uint8Array([2])]; + } + const iterator = + pull(gen(), ...transforms, { signal: ac.signal })[Symbol.asyncIterator](); + assert.strictEqual((await iterator.next()).done, false); + ac.abort(reason); + await assert.rejects(iterator.next(), (error) => error === reason); + await assert.rejects(iterator.next(), (error) => error === reason); + } + // Aborting after the pipeline completed does not change the result. + { + const ac = new AbortController(); + const iterator = + pull(from('x'), ...transforms, { signal: ac.signal })[Symbol.asyncIterator](); + assert.strictEqual((await iterator.next()).done, false); + assert.strictEqual((await iterator.next()).done, true); + ac.abort(); + assert.strictEqual((await iterator.next()).done, true); + } + } } async function testPullNormalizesSourceAtCallTime() { @@ -98,7 +158,7 @@ async function testPullNormalizesSourceAtCallTime() { assert.strictEqual(iteratorCalls, 1); } -function testPullPreAbortOrdering() { +async function testPullPreAbortOrdering() { const reason = new Error('already aborted'); let protocolCalls = 0; const source = { @@ -109,8 +169,11 @@ function testPullPreAbortOrdering() { }; const signal = AbortSignal.abort(reason); - assert.throws(() => pull(source, { signal }), (error) => error === reason); + // Source conversion still happens when pull() is called; the abort is + // reported when the result is read. + const result = pull(source, { signal }); assert.strictEqual(protocolCalls, 1); + await assert.rejects(text(result), (error) => error === reason); assert.throws( () => pull(null, { signal }), { code: 'ERR_INVALID_ARG_TYPE' }, @@ -255,7 +318,7 @@ async function testPullReturnWhileSourceNextPending() { ]); assert.notStrictEqual(result, timeout); - assert.deepStrictEqual(result, { value: undefined, done: true }); + assert.deepStrictEqual({ ...result }, { value: undefined, done: true }); await next; } @@ -519,8 +582,156 @@ async function testTransformOptionsNotShared() { assert.strictEqual(seen[1].mutated, undefined); } +// Stateless transforms get a new options object for every call, and stateful +// transforms one for the pipeline. The options object has only `signal`, does +// not inherit from Object.prototype, and its prototype is frozen, so that a +// transform cannot pass state to others through it. +async function testTransformOptionsShape() { + const seen = []; + const stateless = (chunks, options) => { + seen.push(options); + return chunks; + }; + const stateful = { + async* transform(source, options) { + seen.push(options); + for await (const chunks of source) yield chunks; + }, + }; + const ac = new AbortController(); + await text(pull(from(['a', 'b']), stateless, stateful, + { signal: ac.signal })); + // Stateless: one call per batch plus the flush call. + assert.strictEqual(seen.length, 4); + assert.strictEqual(new Set(seen).size, seen.length); + for (const options of seen) { + assert.strictEqual(options instanceof Object, false); + assert.deepStrictEqual(Object.keys(options), ['signal']); + assert.ok(options.signal instanceof AbortSignal); + assert.strictEqual(Object.isFrozen(Object.getPrototypeOf(options)), true); + assert.match(inspect(options), /^TransformOptions \{ signal: /); + } + assert.strictEqual(Object.getPrototypeOf(seen[0]), + Object.getPrototypeOf(seen[3])); + assert.throws(() => { Object.getPrototypeOf(seen[0]).leak = true; }, + TypeError); +} + // Run the uncaughtException test sequentially (it installs a global handler // that would interfere with concurrent tests). +// Transform pipelines read and close the source like an async generator +// looping over it with for await. The tests below check the parts of that +// behavior that are observable from the source and the transforms' signal. + +// Creates an async iterable source of batches of one-byte chunks with values +// `values`, recording calls in `log`. +function createLoggedSource(log, values, { failAt = -1 } = {}) { + let i = 0; + return { + [Symbol.asyncIterator]() { + return { + async next() { + log.push(`next ${i}`); + if (i === failAt) { + i++; + throw new Error('source failed'); + } + if (i >= values.length) return { done: true, value: undefined }; + return { done: false, value: [Uint8Array.of(values[i++])] }; + }, + async return() { + log.push('return'); + return { done: true, value: undefined }; + }, + }; + }, + }; +} + +// A transform recording its calls, and the abort of its signal, in `log`. +function createLoggedTransform(log, transform = (chunks) => chunks) { + let listening = false; + return (chunks, options) => { + log.push(chunks === null ? 'flush' : `transform ${chunks[0][0]}`); + if (!listening) { + listening = true; + options.signal.addEventListener('abort', () => { + log.push(`abort: ${options.signal.reason.message}`); + }); + } + return transform(chunks, options); + }; +} + +async function testTransformErrorClosesSource() { + const fail = (chunks) => { + if (chunks?.[0][0] === 2) throw new Error('transform failed'); + return chunks; + }; + for (const transform of [fail, async (chunks) => fail(chunks)]) { + const log = []; + await assert.rejects(async () => { + // eslint-disable-next-line no-unused-vars + for await (const _ of pull(createLoggedSource(log, [1, 2, 3]), + createLoggedTransform(log, transform))); + }, /transform failed/); + assert.deepStrictEqual(log, [ + 'next 0', 'transform 1', 'next 1', 'transform 2', 'return', + 'abort: transform failed', + ]); + } +} + +async function testTransformSourceErrorDoesNotCloseSource() { + const log = []; + await assert.rejects(async () => { + // eslint-disable-next-line no-unused-vars + for await (const _ of pull(createLoggedSource(log, [1], { failAt: 1 }), + createLoggedTransform(log))); + }, /source failed/); + assert.deepStrictEqual(log, [ + 'next 0', 'transform 1', 'next 1', 'abort: source failed', + ]); +} + +async function testPipeToTransformsStoppedEarly() { + // When the writer fails, the transforms are closed, closing the source, and + // their signal is aborted. + const log = []; + let writes = 0; + await assert.rejects(pipeTo( + createLoggedSource(log, [1, 2, 3]), createLoggedTransform(log), { + write() { + if (++writes === 2) throw new Error('write failed'); + }, + }), /write failed/); + assert.deepStrictEqual(log, [ + 'next 0', 'transform 1', 'next 1', 'transform 2', 'return', + 'abort: Aborted', + ]); +} + +async function testTransformReturnClosesOutputAndSource() { + // A transform output that is iterated asynchronously is closed, and then + // the source, when the pipeline is stopped while reading it. + const log = []; + const iterator = pull(createLoggedSource(log, [1, 2]), + createLoggedTransform(log, async function*() { + try { + yield Uint8Array.of(1); + yield Uint8Array.of(2); + } finally { + log.push('output closed'); + } + }))[Symbol.asyncIterator](); + assert.strictEqual((await iterator.next()).done, false); + assert.strictEqual((await iterator.return()).done, true); + assert.strictEqual((await iterator.next()).done, true); + assert.deepStrictEqual(log, [ + 'next 0', 'transform 1', 'output closed', 'abort: Aborted', 'return', + ]); +} + (async () => { await Promise.all([ testPullIdentity(), @@ -528,6 +739,7 @@ async function testTransformOptionsNotShared() { testPullStatefulTransform(), testPullStatefulTransformReceiver(), testPullWithAbortSignal(), + testPullKeepsRejectingAfterAbort(), testPullNormalizesSourceAtCallTime(), testPullPreAbortOrdering(), testPullChainedTransforms(), @@ -554,6 +766,11 @@ async function testTransformOptionsNotShared() { testTransformReturnsArrayBuffer(), testPipeToStringSource(), testTransformOptionsNotShared(), + testTransformOptionsShape(), + testTransformErrorClosesSource(), + testTransformSourceErrorDoesNotCloseSource(), + testPipeToTransformsStoppedEarly(), + testTransformReturnClosesOutputAndSource(), ]); // Run after all concurrent tests complete to avoid global handler races await testTransformSignalListenerErrorOnSourceError(); diff --git a/test/parallel/test-stream-iter-push-backpressure.js b/test/parallel/test-stream-iter-push-backpressure.js index 9fb3325f3423..58ba2d331dcb 100644 --- a/test/parallel/test-stream-iter-push-backpressure.js +++ b/test/parallel/test-stream-iter-push-backpressure.js @@ -150,6 +150,22 @@ async function testStrictPendingQueueOverflow() { await iter.return(); } +async function testSmallBudget() { + // An explicit budget below the 16384-byte default is honored. + const { writer, readable } = push({ budget: 4 }); + assert.strictEqual(writer.writeSync('abc'), true); + assert.strictEqual(writer.canWrite, true); + // The buffer may overshoot the budget by one write. + assert.strictEqual(writer.writeSync('defgh'), true); + assert.strictEqual(writer.canWrite, false); + assert.strictEqual(writer.writeSync('i'), false); + const write = writer.write('ij'); + const result = text(readable); + await write; + writer.endSync(); + assert.strictEqual(await result, 'abcdefghij'); +} + Promise.all([ testStrictBackpressure(), testDropOldest(), @@ -157,4 +173,5 @@ Promise.all([ testBlockBackpressure(), testBlockWriteSyncDoesNotEnqueue(), testStrictPendingQueueOverflow(), + testSmallBudget(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-push-basic.js b/test/parallel/test-stream-iter-push-basic.js index 9b1c044cac47..ad7eab5f53a4 100644 --- a/test/parallel/test-stream-iter-push-basic.js +++ b/test/parallel/test-stream-iter-push-basic.js @@ -3,7 +3,7 @@ const common = require('../common'); const assert = require('assert'); -const { push, text } = require('stream/iter'); +const { from, push, text } = require('stream/iter'); async function testBasicWriteRead() { const { writer, readable } = push(); @@ -174,6 +174,21 @@ async function testInvalidBackpressure() { } } +async function testReturnValue() { + // return(value) resolves with `value`, read directly or through from(). + for (const getIterator of [ + (readable) => readable[Symbol.asyncIterator](), + (readable) => from(readable)[Symbol.asyncIterator](), + ]) { + const { writer, readable } = push(); + const iterator = getIterator(readable); + const result = await iterator.return('value'); + assert.strictEqual(result.done, true); + assert.strictEqual(result.value, 'value'); + assert.strictEqual(writer.canWrite, null); + } +} + Promise.all([ testBasicWriteRead(), testMultipleWrites(), @@ -187,4 +202,5 @@ Promise.all([ testConsumerBreakWriteSyncReturnsFalse(), testPushWithTransforms(), testInvalidBackpressure(), + testReturnValue(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-reason-propagation.js b/test/parallel/test-stream-iter-reason-propagation.js index 7511dbb2c410..58ae7d4e5f4d 100644 --- a/test/parallel/test-stream-iter-reason-propagation.js +++ b/test/parallel/test-stream-iter-reason-propagation.js @@ -249,8 +249,7 @@ async function testCompletedBroadcastConsumerStaysCompleted() { await iterator.return(); writer.fail(undefined); - assert.deepStrictEqual(await iterator.next(), { - __proto__: null, + assert.deepStrictEqual({ ...await iterator.next() }, { done: true, value: undefined, }); diff --git a/test/parallel/test-stream-iter-resizable-buffers.js b/test/parallel/test-stream-iter-resizable-buffers.js index b66973393cac..1691893d347a 100644 --- a/test/parallel/test-stream-iter-resizable-buffers.js +++ b/test/parallel/test-stream-iter-resizable-buffers.js @@ -167,6 +167,125 @@ async function testPipeRejectsWriterResize() { ); } +// Single-chunk batches are checked around writeSync() without a snapshot +// object. Detaching or resizing the view in writeSync() must still be +// rejected, for views on ArrayBuffers and on growable SharedArrayBuffers. +async function testPipeRejectsSyncWriteDetachOrResize() { + const cases = [ + ['detach', () => new ArrayBuffer(2), (buffer) => buffer.transfer()], + ['resize', () => new ArrayBuffer(1, { maxByteLength: 2 }), + (buffer) => buffer.resize(2)], + ['grow shared', () => new SharedArrayBuffer(1, { maxByteLength: 2 }), + (buffer) => buffer.grow(2)], + ]; + for (const [, createBuffer, change] of cases) { + const asyncBuffer = createBuffer(); + await assert.rejects(pipeTo([new Uint8Array(asyncBuffer)], { + writeSync() { + change(asyncBuffer); + return true; + }, + write: common.mustNotCall(), + fail: common.mustCall(), + }), kResizeError); + + const syncBuffer = createBuffer(); + assert.throws(() => pipeToSync([new Uint8Array(syncBuffer)], { + writeSync() { + change(syncBuffer); + return true; + }, + fail: common.mustCall(), + }, { preventClose: true }), kResizeError); + } + + // An unchanged view is written; when writeSync() declines it, pipeTo() + // falls back to write() for it. + const chunk = Uint8Array.of(1, 2); + const written = []; + assert.strictEqual(await pipeTo([chunk], { + writeSync: () => false, + write(value) { written.push(value); }, + }, { preventClose: true }), 2); + assert.deepStrictEqual(written, [chunk]); +} + +// Batches of several chunks written one at a time are checked against the +// byteLengths recorded when the batch was accepted, before and after each +// writeSync(). Detaching a later chunk, or the chunk being written, must be +// rejected. +async function testPipeRejectsDetachInMultiChunkBatch() { + for (const detachIndex of [1, 0]) { + const asyncBuffers = [new ArrayBuffer(2), new ArrayBuffer(2)]; + const asyncWritten = []; + await assert.rejects(pipeTo([asyncBuffers.map((b) => new Uint8Array(b))], { + writeSync(chunk) { + asyncWritten.push(chunk.byteLength); + if (asyncWritten.length === 1) asyncBuffers[detachIndex].transfer(); + return true; + }, + write: common.mustNotCall(), + fail: common.mustCall(), + }), kResizeError); + assert.deepStrictEqual(asyncWritten, [2]); + + const syncBuffers = [new ArrayBuffer(2), new ArrayBuffer(2)]; + const syncWritten = []; + assert.throws(() => pipeToSync([syncBuffers.map((b) => new Uint8Array(b))], { + writeSync(chunk) { + syncWritten.push(chunk.byteLength); + if (syncWritten.length === 1) syncBuffers[detachIndex].transfer(); + return true; + }, + fail: common.mustCall(), + }, { preventClose: true }), kResizeError); + assert.deepStrictEqual(syncWritten, [2]); + } + + // When writeSync() declines a chunk, pipeTo() writes the rest of the batch + // with write(). + const chunks = [Uint8Array.of(1), Uint8Array.of(2, 3), Uint8Array.of(4)]; + const written = []; + assert.strictEqual(await pipeTo([chunks], { + writeSync(chunk) { + if (chunk === chunks[1]) return false; + written.push(chunk); + return true; + }, + write(chunk) { written.push(chunk); }, + }, { preventClose: true }), 4); + assert.deepStrictEqual(written, chunks); +} + +async function testConsumersRejectDetachedViews() { + // Views of fixed-length buffers are tracked without a full snapshot; they + // must still be rejected when detached after being accepted. + const asyncBuffer = new ArrayBuffer(2); + async function* asyncSource() { + yield [new Uint8Array(asyncBuffer), Uint8Array.of(1)]; + asyncBuffer.transfer(); + } + await assert.rejects(array(asyncSource()), kResizeError); + const limitedBuffer = new ArrayBuffer(2); + async function* limitedSource() { + yield [new Uint8Array(limitedBuffer)]; + limitedBuffer.transfer(); + } + await assert.rejects(array(limitedSource(), { limit: 10 }), kResizeError); + + const syncBuffer = new ArrayBuffer(2); + function* syncSource() { + yield [new Uint8Array(syncBuffer, 1)]; + syncBuffer.transfer(); + } + assert.throws(() => arraySync(syncSource()), kResizeError); + + // Unchanged views are returned as-is. + const chunk = new Uint8Array(4); + const [result] = arraySync([[chunk]]); + assert.strictEqual(result, chunk); +} + Promise.all([ testBufferedViewMutationRejected(), testDropOldestUsesAcceptedByteLength(), @@ -174,5 +293,8 @@ Promise.all([ testBroadcastRejectsResizedBufferedView(), testShareRejectsResizedBufferedView(), testConsumersRejectResizedViews(), + testConsumersRejectDetachedViews(), testPipeRejectsWriterResize(), + testPipeRejectsSyncWriteDetachOrResize(), + testPipeRejectsDetachInMultiChunkBatch(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-share-async.js b/test/parallel/test-stream-iter-share-async.js index ab6bc0914798..8966c82c74e6 100644 --- a/test/parallel/test-stream-iter-share-async.js +++ b/test/parallel/test-stream-iter-share-async.js @@ -380,6 +380,64 @@ async function testShareLateJoiningConsumer() { assert.strictEqual(data2, ''); } +async function testShareRetainsBufferWhenAllConsumersDetach() { + // Data that a consumer had not read yet must stay available to consumers + // that attach after every previous consumer has detached. + const enc = new TextEncoder(); + async function* gen() { + yield [enc.encode('a')]; + yield [enc.encode('b')]; + yield [enc.encode('c')]; + } + const shared = share(gen(), { budget: 16384 }); + const c1 = shared.pull()[Symbol.asyncIterator](); + const c2 = shared.pull()[Symbol.asyncIterator](); + assert.deepStrictEqual((await c1.next()).value, [enc.encode('a')]); + // c2 has not read 'a' yet; it is the last consumer to detach. + await c1.return(); + await c2.return(); + assert.strictEqual(shared.consumerCount, 0); + + assert.strictEqual(await text(shared.pull()), 'abc'); +} + +async function testShareDropOldestSplitsOversizedBatches() { + // from() combines the values of this sync generator into a single batch + // that is much larger than the budget. Evicting that batch as a whole would + // leave the slower consumer with nothing at all. + function* source() { + for (let i = 0; i < 50; i++) { + const chunk = new Uint8Array(4096); + chunk[0] = i; + yield chunk; + } + } + const shared = share(source(), { + budget: 65536, + backpressure: 'drop-oldest', + }); + const fast = shared.pull()[Symbol.asyncIterator](); + const slow = shared.pull()[Symbol.asyncIterator](); + + const fastSeen = []; + for (let r = await fast.next(); !r.done; r = await fast.next()) { + for (const chunk of r.value) fastSeen.push(chunk[0]); + } + assert.deepStrictEqual(fastSeen, Array.from({ length: 50 }, (_, i) => i)); + + const slowSeen = []; + for (let r = await slow.next(); !r.done; r = await slow.next()) { + for (const chunk of r.value) slowSeen.push(chunk[0]); + } + // The slow consumer lost the oldest chunks but keeps an in-order suffix + // that fits the budget. + assert.ok(slowSeen.length > 0); + assert.ok(slowSeen.length * 4096 < 65536); + assert.deepStrictEqual( + slowSeen, + Array.from({ length: slowSeen.length }, (_, i) => 50 - slowSeen.length + i)); +} + async function testShareConsumerBreak() { // Verify that a consumer breaking mid-iteration detaches properly const enc = new TextEncoder(); @@ -474,6 +532,8 @@ Promise.all([ testShareSourceError(), testShareSourceErrorFollowsBufferedData(), testShareLateJoiningConsumer(), + testShareRetainsBufferWhenAllConsumersDetach(), + testShareDropOldestSplitsOversizedBatches(), testShareConsumerBreak(), testShareMultipleConsumersConcurrentPull(), testShareConsumerConcurrentNextCalls(), diff --git a/test/parallel/test-stream-iter-share-coverage.js b/test/parallel/test-stream-iter-share-coverage.js index 8b0cc1967f2f..586ff24a9417 100644 --- a/test/parallel/test-stream-iter-share-coverage.js +++ b/test/parallel/test-stream-iter-share-coverage.js @@ -112,8 +112,7 @@ async function testCompletedSyncConsumerStaysCompleted() { assert.strictEqual(error, reason); } assert.strictEqual(caught, true); - assert.deepStrictEqual(completed.next(), { - __proto__: null, + assert.deepStrictEqual({ ...completed.next() }, { done: true, value: undefined, }); diff --git a/test/parallel/test-stream-iter-share-sync.js b/test/parallel/test-stream-iter-share-sync.js index fd0184df9bce..a843e7311dd1 100644 --- a/test/parallel/test-stream-iter-share-sync.js +++ b/test/parallel/test-stream-iter-share-sync.js @@ -146,72 +146,168 @@ function testShareSyncRejectsUnbounded() { ); } -function testShareSyncDropNewest() { - let pulls = 0; - function* source() { - for (let i = 0; i < 4; i++) { - pulls++; - const chunk = new Uint8Array(16384); - chunk[0] = i; - yield [chunk]; - } - } +function testShareSyncRejectsDropNewest() { + // A synchronous consumer can neither wait for the slowest consumer nor keep + // discarding until it advances, so 'drop-newest' is rejected like + // 'unbounded'. + assert.throws( + () => shareSync(fromSync('data'), { backpressure: 'drop-newest' }), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); +} - const shared = shareSync(source(), { - budget: 16384, - backpressure: 'drop-newest', - }); - const fast = shared.pull()[Symbol.iterator](); - const slow = shared.pull()[Symbol.iterator](); +// shareSync() accepts string source directly (normalized via fromSync()) +function testShareSyncRetainsBufferWhenAllConsumersDetach() { + // Data that a consumer had not read yet must stay available to consumers + // that attach after every previous consumer has detached. + const enc = new TextEncoder(); + function* gen() { + yield [enc.encode('a')]; + yield [enc.encode('b')]; + yield [enc.encode('c')]; + } + const shared = shareSync(gen(), { budget: 16384 }); + const c1 = shared.pull()[Symbol.iterator](); + const c2 = shared.pull()[Symbol.iterator](); + assert.deepStrictEqual(c1.next().value, [enc.encode('a')]); + c1.return(); + c2.return(); + assert.strictEqual(shared.consumerCount, 0); - assert.strictEqual(fast.next().value[0][0], 0); + assert.strictEqual(textSync(shared.pull()), 'abc'); +} - // The budget is exhausted and the slow consumer cannot advance while this - // call is running, so exactly one entry is dropped and no value is - // available. The consumer is not detached. - assert.strictEqual(fast.next().done, true); - assert.strictEqual(pulls, 2); +function testShareSyncStrictBackpressureDetaches() { + for (const transformed of [false, true]) { + function* source() { + for (let i = 0; i < 10; i++) { + yield [new Uint8Array(16384)]; + } + } + const shared = shareSync(source(), { + budget: 32768, + backpressure: 'strict', + }); + const consumer = transformed ? + shared.pull((chunks) => chunks) : shared.pull(); + const fast = consumer[Symbol.iterator](); + // This consumer prevents the buffer from being trimmed. + const slow = shared.pull()[Symbol.iterator](); + + fast.next(); + fast.next(); + assert.throws(() => fast.next(), { code: 'ERR_OUT_OF_RANGE' }); + // The rejected consumer is detached, as with the async share. + assert.strictEqual(shared.consumerCount, 1); + assert.strictEqual(fast.next().done, true); - // The slow consumer still sees the buffered entry, which releases budget. - assert.strictEqual(slow.next().value[0][0], 0); + // The detached consumer no longer pins the buffer, so the remaining + // consumer can read the whole source. + let count = 0; + while (!slow.next().done) count++; + assert.strictEqual(count, 10); + } +} - // Entry 1 was dropped for every consumer, so both resume at entry 2. - assert.strictEqual(slow.next().value[0][0], 2); - assert.strictEqual(pulls, 3); - assert.strictEqual(fast.next().value[0][0], 2); +function testShareSyncStrictForOfDoesNotWedgeOthers() { + // for...of does not call return() when next() throws. The consumer that + // hit the budget must still not keep the other consumers from reading. + function* source() { + for (let i = 0; i < 20; i++) yield [new Uint8Array(8192)]; + } + const shared = shareSync(source(), { budget: 16384, backpressure: 'strict' }); + const slow = shared.pull()[Symbol.iterator](); + assert.throws(() => { + // eslint-disable-next-line no-unused-vars + for (const _ of shared.pull()) { /* consume */ } + }, { code: 'ERR_OUT_OF_RANGE' }); + assert.strictEqual(shared.consumerCount, 1); + let count = 0; + while (!slow.next().done) count++; + assert.strictEqual(count, 20); } -// Regression test: a full buffer must not spin pulling-and-discarding from an -// unbounded source, since discarding never reclaims budget. -function testShareSyncDropNewestUnboundedSource() { - let pulls = 0; +function testShareSyncDropOldestSplitsOversizedBatches() { + // fromSync() combines the values of this generator into a single batch + // that is much larger than the budget. Evicting that batch as a whole would + // leave the slower consumer with nothing at all. function* source() { - for (;;) { - pulls++; - yield [new Uint8Array(16384)]; + for (let i = 0; i < 50; i++) { + const chunk = new Uint8Array(4096); + chunk[0] = i; + yield chunk; } } - const shared = shareSync(source(), { - budget: 16384, - backpressure: 'drop-newest', + budget: 65536, + backpressure: 'drop-oldest', }); const fast = shared.pull()[Symbol.iterator](); - shared.pull(); + const slow = shared.pull()[Symbol.iterator](); - assert.strictEqual(fast.next().done, false); - assert.strictEqual(pulls, 1); + const fastSeen = []; + for (let r = fast.next(); !r.done; r = fast.next()) { + for (const chunk of r.value) fastSeen.push(chunk[0]); + } + assert.deepStrictEqual(fastSeen, Array.from({ length: 50 }, (_, i) => i)); - // Each blocked call drops at most one entry and returns without a value. - for (let i = 0; i < 3; i++) { - assert.strictEqual(fast.next().done, true); - assert.strictEqual(pulls, 2 + i); + const slowSeen = []; + for (let r = slow.next(); !r.done; r = slow.next()) { + for (const chunk of r.value) slowSeen.push(chunk[0]); } + // The slow consumer lost the oldest chunks but keeps an in-order suffix + // that fits the budget. + assert.ok(slowSeen.length > 0); + assert.ok(slowSeen.length * 4096 < 65536); + assert.deepStrictEqual( + slowSeen, + Array.from({ length: slowSeen.length }, (_, i) => 50 - slowSeen.length + i)); +} - shared.cancel(); +function testShareSyncReentrantSourceRead() { + const enc = new TextEncoder(); + let sibling; + let reentrantError; + function* source() { + yield [enc.encode('a')]; + try { + sibling.next(); + } catch (err) { + reentrantError = err; + } + yield [enc.encode('b')]; + } + const shared = shareSync(source()); + const c1 = shared.pull()[Symbol.iterator](); + sibling = shared.pull()[Symbol.iterator](); + + assert.deepStrictEqual(c1.next().value, [enc.encode('a')]); + assert.deepStrictEqual(sibling.next().value, [enc.encode('a')]); + // Pulling 'b' runs the source, which tries to read its own share. + assert.deepStrictEqual(c1.next().value, [enc.encode('b')]); + assert.strictEqual(reentrantError?.code, 'ERR_INVALID_STATE'); + // The failed re-entrant read left the share intact. + assert.deepStrictEqual(sibling.next().value, [enc.encode('b')]); + assert.strictEqual(c1.next().done, true); + assert.strictEqual(sibling.next().done, true); +} + +function testShareSyncReentrantSourceReadUncaught() { + let sibling; + function* source() { + yield [new Uint8Array(1)]; + sibling.next(); + } + const shared = shareSync(source()); + const c1 = shared.pull()[Symbol.iterator](); + sibling = shared.pull()[Symbol.iterator](); + c1.next(); + sibling.next(); + // The error escapes the source, so it becomes the share's source error. + assert.throws(() => c1.next(), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => sibling.next(), { code: 'ERR_INVALID_STATE' }); } -// shareSync() accepts string source directly (normalized via fromSync()) function testShareSyncStringSource() { const shared = shareSync('hello-sync-share'); const result = textSync(shared.pull()); @@ -227,7 +323,12 @@ Promise.all([ testShareSyncCancelWithFalsyReason(), testShareSyncSourceError(), testShareSyncRejectsUnbounded(), - testShareSyncDropNewest(), - testShareSyncDropNewestUnboundedSource(), + testShareSyncRejectsDropNewest(), + testShareSyncDropOldestSplitsOversizedBatches(), + testShareSyncReentrantSourceRead(), + testShareSyncReentrantSourceReadUncaught(), testShareSyncStringSource(), + testShareSyncRetainsBufferWhenAllConsumersDetach(), + testShareSyncStrictBackpressureDetaches(), + testShareSyncStrictForOfDoesNotWedgeOthers(), ]).then(common.mustCall()); diff --git a/test/parallel/test-stream-iter-transform-buffering.js b/test/parallel/test-stream-iter-transform-buffering.js index b1b813f60b9b..95b9a71f65c2 100644 --- a/test/parallel/test-stream-iter-transform-buffering.js +++ b/test/parallel/test-stream-iter-transform-buffering.js @@ -4,8 +4,13 @@ const common = require('../common'); const assert = require('assert'); const { brotliDecompressSync, gunzipSync, gzipSync } = require('zlib'); -const { compressBrotli, compressGzip, decompressGzip } = require('zlib/iter'); -const { bytes, from, pull } = require('stream/iter'); +const { + compressBrotli, + compressGzip, + decompressGzip, + decompressGzipSync, +} = require('zlib/iter'); +const { bytes, from, fromSync, pull, pullSync } = require('stream/iter'); async function testDecompressionOutputIsBounded() { let input = Buffer.alloc(32 * 1024 * 1024, 0x61); @@ -38,6 +43,34 @@ async function testSmallChunkSizeDecompression() { assert.deepStrictEqual(Buffer.from(output), input); } +// With a chunk size that does not divide the batch size, output collected +// beyond a batch is taken a batch at a time, from the front. +async function testPartialBatchDrains() { + const input = Buffer.alloc(1024 * 1024); + for (let i = 0; i < input.length; i++) input[i] = (i * 7 + (i >> 12)) & 0xff; + const compressed = gzipSync(input); + const batchSizes = (batches) => batches.map((batch) => { + return batch.reduce((total, chunk) => total + chunk.length, 0); + }); + + const asyncBatches = []; + for await (const batch of pull(from(compressed), + decompressGzip({ chunkSize: 1000 }))) { + asyncBatches.push(batch); + } + const syncBatches = [ + ...pullSync(fromSync(compressed), decompressGzipSync({ chunkSize: 1000 })), + ]; + for (const batches of [asyncBatches, syncBatches]) { + assert.deepStrictEqual(Buffer.concat(batches.flat()), input); + assert.ok(batches.length > 1); + // A batch is closed once it holds 64 KiB. + for (const size of batchSizes(batches)) { + assert.ok(size <= 64 * 1024 + 1000, `batch of ${size} bytes`); + } + } +} + // Deterministic incompressible data. Brotli's buffering decisions depend on // content, so random data would make the tests below flaky. function incompressible(size) { @@ -108,6 +141,7 @@ async function testSourceReturnFailuresAreIgnored() { await testDecompressionOutputIsBounded(); await Promise.all([ testSmallChunkSizeDecompression(), + testPartialBatchDrains(), testLargeFinishOutput(), testExplicitFlushSignal(), testSourceReturnFailuresAreIgnored(), diff --git a/test/parallel/test-stream-iter-validation.js b/test/parallel/test-stream-iter-validation.js index b130a92ae3de..16ff12c060e7 100644 --- a/test/parallel/test-stream-iter-validation.js +++ b/test/parallel/test-stream-iter-validation.js @@ -21,15 +21,18 @@ const { // push() validation // ============================================================================= -// Budget must be integer >= 16384 +// Budget must be an integer >= 1 assert.throws(() => push({ budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' }); -assert.throws(() => push({ budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' }); -// Values < 16384 are rejected +// WebIDL conversion truncates fractions: 0.5 becomes 0, 1.5 becomes 1. +assert.throws(() => push({ budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' }); +assert.strictEqual(push({ budget: 1.5 }).writer.canWrite, true); +// Values < 1 are rejected assert.throws(() => push({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => push({ budget: -1 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => push({ budget: -100 }), { code: 'ERR_OUT_OF_RANGE' }); -assert.throws(() => push({ budget: 16383 }), { code: 'ERR_OUT_OF_RANGE' }); -// 16384 is the minimum accepted value +// Only the default must be at least 16384; smaller explicit budgets are valid +assert.strictEqual(push({ budget: 1 }).writer.canWrite, true); +assert.strictEqual(push({ budget: 16383 }).writer.canWrite, true); assert.strictEqual(push({ budget: 16384 }).writer.canWrite, true); // MAX_SAFE_INTEGER is accepted assert.strictEqual(push({ budget: Number.MAX_SAFE_INTEGER }).writer.canWrite, @@ -87,11 +90,11 @@ assert.throws(() => duplex({ b: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' }); // Budget validation (cascades through to push()) assert.throws(() => duplex({ budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' }); -assert.throws(() => duplex({ budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' }); +assert.throws(() => duplex({ budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => duplex({ budget: Number.MAX_SAFE_INTEGER + 1 }), { code: 'ERR_OUT_OF_RANGE' }); -// Values < 16384 are rejected (both directions) +// Values < 1 are rejected (both directions) assert.throws(() => duplex({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' }); // MAX_SAFE_INTEGER is accepted { @@ -129,17 +132,16 @@ assert.throws(() => pullSync(fromSync('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' // ============================================================================= assert.throws(() => broadcast({ budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' }); -assert.throws(() => broadcast({ budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' }); +assert.throws(() => broadcast({ budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => broadcast({ budget: Number.MAX_SAFE_INTEGER + 1 }), { code: 'ERR_OUT_OF_RANGE' }); -// Values < 16384 are rejected +// Values < 1 are rejected assert.throws(() => broadcast({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => broadcast({ budget: -1 }), { code: 'ERR_OUT_OF_RANGE' }); -assert.throws(() => broadcast({ budget: 16383 }), { code: 'ERR_OUT_OF_RANGE' }); -// 16384 is the minimum accepted value +// Small explicit budgets are accepted { - const bc = broadcast({ budget: 16384 }); + const bc = broadcast({ budget: 1 }); bc.broadcast.push(); assert.strictEqual(bc.writer.canWrite, true); bc.writer.endSync(); @@ -235,7 +237,7 @@ assert.throws( assert.throws(() => share(42), { code: 'ERR_INVALID_ARG_TYPE' }); assert.throws(() => share(from('a'), { budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' }); -assert.throws(() => share(from('a'), { budget: 1.5 }), { code: 'ERR_OUT_OF_RANGE' }); +assert.throws(() => share(from('a'), { budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => share(from('a'), { budget: Number.MAX_SAFE_INTEGER + 1 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => share(from('a'), { signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' }); @@ -250,28 +252,28 @@ assert.throws(() => share(from('a'), { backpressure: 'bad' }), { code: 'ERR_INVA assert.strictEqual(shared.consumerCount, 0); } -// share() values < 16384 are rejected +// share() values < 1 are rejected assert.throws(() => share(from('a'), { budget: 0 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => share(from('a'), { budget: -1 }), { code: 'ERR_OUT_OF_RANGE' }); -// 16384 is the minimum, MAX_SAFE_INTEGER is accepted -share(from('a'), { budget: 16384 }).cancel(); +// 1 is the minimum, MAX_SAFE_INTEGER is accepted +share(from('a'), { budget: 1 }).cancel(); share(from('a'), { budget: Number.MAX_SAFE_INTEGER }).cancel(); assert.throws(() => shareSync(42), { code: 'ERR_INVALID_ARG_TYPE' }); assert.throws(() => shareSync(fromSync('a'), { budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' }); -assert.throws(() => shareSync(fromSync('a'), { budget: 1.5 }), +assert.throws(() => shareSync(fromSync('a'), { budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => shareSync(fromSync('a'), { budget: Number.MAX_SAFE_INTEGER + 1 }), { code: 'ERR_OUT_OF_RANGE' }); -// shareSync() values < 16384 are rejected +// shareSync() values < 1 are rejected assert.throws(() => shareSync(fromSync('a'), { budget: 0 }), { code: 'ERR_OUT_OF_RANGE' }); assert.throws(() => shareSync(fromSync('a'), { budget: -1 }), { code: 'ERR_OUT_OF_RANGE' }); -// 16384 is the minimum, MAX_SAFE_INTEGER is accepted -shareSync(fromSync('a'), { budget: 16384 }).cancel(); +// 1 is the minimum, MAX_SAFE_INTEGER is accepted +shareSync(fromSync('a'), { budget: 1 }).cancel(); shareSync(fromSync('a'), { budget: Number.MAX_SAFE_INTEGER }).cancel(); // Share.from / SyncShare.fromSync reject non-iterable diff --git a/test/parallel/test-stream-iter-writable-interop.js b/test/parallel/test-stream-iter-writable-interop.js index af2e05aae94c..f00aae924259 100644 --- a/test/parallel/test-stream-iter-writable-interop.js +++ b/test/parallel/test-stream-iter-writable-interop.js @@ -718,7 +718,7 @@ async function testWriteAfterEnd() { await assert.rejects( writer.write('should fail'), - { code: 'ERR_STREAM_WRITE_AFTER_END' }, + { code: 'ERR_STREAM_WRITE_AFTER_END', name: 'TypeError' }, ); }