readableStreamPipeTo allocated, for every chunk written to the
destination, a { promise, resolve, reject } write request record that
it immediately marked as handled, and drove its loop with an async
step()/run() pair whose implicit promises cost one allocation and one
reaction per iteration. The parked-read path additionally allocated a
read request object, a PromiseWithResolvers record, and a microtask
closure per chunk; this is the steady state for pipeThrough, since a
TransformStream's readable side has a high water mark of zero.
Replace the per-write records with a single per-pipe tracker that the
write request queue holds once per pending write and whose
resolve()/reject() methods maintain a pending-write count, drive the
pump loop with plain callbacks instead of async functions, and reuse
one read request and one forwarding function across all chunks, the
same pattern tee uses since c543cfb72b.
Benchmark results (benchmark/compare.js --runs 20):
webstreams/pipe-to.js +29.9% to +35.8% across all 16 configurations
(all 99.9% confidence); a pipeThrough(TransformStream) passthrough
loop improves ~17%; every other webstreams benchmark is unchanged.
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/64890
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
The byte controller's [[pendingPullIntos]] list was still a plain array
consumed with ArrayPrototypeShift, while every other per-chunk queue in
the WHATWG streams implementation has moved to the Queue ring buffer.
BYOB reads push and shift one descriptor per read, and Array.prototype
shift has real per-call cost even at length 1.
Back the descriptor list with the same lazily materialized Queue used
for the request queues, so constructing a byte stream still allocates
no descriptor storage.
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/64818
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Aviv Keller <me@aviv.sh>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
writableStreamUpdateBackpressure ran two release-mode assertions and
took a precomputed backpressure value that every caller derived with a
separate GetBackpressure/GetDesiredSize call pair. All callers already
hold the 'writable', not-close-queued invariant the assertions checked,
so derive the backpressure from the desired size inside the function and
drop the assertions and the helper. This runs up to twice per write.
writer.write() loop: +10%, pipe-to: +4.6% avg over 16 configs (local
harness / benchmark, all configs positive).
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/64451
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
The [[queue]] backing the controllers became a ring buffer, but the
read and write request queues (readRequests, readIntoRequests,
writeRequests) were left as plain arrays consumed with
ArrayPrototypeShift, which is O(n) and, even for the single pending
request of the await-each regime, far slower than an indexed head
advance. Back them with the same Queue, materialized lazily from the
shared empty queue so acquiring a reader or constructing a writer
allocates no request storage until a read or write actually parks.
pipe-to: +4.7% to +9.4% (all 16 configs, ***)
readable-read type=byob: +2.3% (**)
parked read loop: +15%, write loop: +11% (local harness)
Follow-up to https://github.com/nodejs/node/pull/64312.
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/64431
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Skip the size algorithm call and its result validation when the size
algorithm is the default one, and stop re-running the full
ShouldCallPull predicate at per-chunk call sites where its inputs are
already established.
readable-async-iterator type=normal: +16.6% (***)
readable-read-buffered: +13.9% to +27.5% (**/***)
pipe-to: +3.7% to +6.0% (15/16 configs significant)
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/64320
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
The [[queue]] backing every default readable/writable controller was a
plain array of { value, size } wrappers consumed with
ArrayPrototypeShift, so each buffered chunk allocated a wrapper object
and each dequeue moved (or forced the engine to re-linearize) the
remaining elements; the byte controller queue paid the same shift cost
for its chunk descriptor records.
Replace the array with a power-of-two ring buffer. Default controller
queues store each entry as (value, size) in two consecutive slots, so
the per-chunk wrapper allocation disappears; the byte controller keeps
its descriptor records (they are mutated in place at the head) in
single slots. Controllers start from (and are reset to) a shared
immutable empty queue, so constructing a stream allocates no queue
storage until a chunk is actually buffered. Enqueues measured by the
internal default size algorithm (never observable by user code, always
returns 1, cannot throw) skip the algorithm call and its try/catch
entirely.
The layout mirrors what Bun/WebKit use for the same spec structure:
[[queue]] as a ring-buffer deque (WTF::Deque in Bun's
src/jsc/bindings/webcore/streams/StreamQueue.h), the pure-JS ring
buffer in Bun's src/js/internal/fifo.ts, and the trivial-size-algorithm
bypass in
src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp.
benchmark/compare.js against the unmodified baseline (30-run capture
plus an independent 15-run repeat, Welch t-test, all p < 1e-5):
webstreams/pipe-to.js +12-17% across all sixteen high-water-mark
configurations, readable-read-buffered +20% (bufferSize=1) to +49%
(bufferSize=1000), readable-async-iterator +21%. No stable significant
regression across the rest of the webstreams suite: the creation.js and
readable-read.js deltas seen in the full-suite capture disappear in
isolated 60-run rechecks.
Refs: https://github.com/oven-sh/bun/blob/main/src/js/internal/fifo.ts
Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Assisted-by: Grok (Grok Build)
PR-URL: https://github.com/nodejs/node/pull/64312
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
for await / reader.read() loops over byte streams were ~4x slower than
over default streams. Three per-chunk costs, none required by the spec:
- ArrayBufferViewGetBuffer/ByteLength/ByteOffset went through
ReflectGet(view.constructor.prototype, ...), a reflective get that is
~3.5x slower than the original prototype getters from primordials and
spoofable through a user-defined .constructor to boot.
- The buffered fast paths in ReadableStreamDefaultReader.read() and the
async iterator only covered default controllers, so byte streams with
queued data still allocated a read request and PromiseWithResolvers
per chunk. Byte-queue dequeue is fully synchronous (it is the
queue-filled arm of the byte controller's pull steps), so both fast
paths now resolve directly from the byte queue.
- readableByteStreamControllerEnqueue re-ran the reader brand check and
re-loaded the read request list four times per chunk across
HasDefaultReader / ProcessReadRequestsUsingQueue / GetNumReadRequests
/ FulfillReadRequest; it now does a single pass. The async iterator
also reuses its read request object across reads (at most one is ever
in flight).
benchmark/webstreams interleaved same-day A/B, --runs 10:
readable-async-iterator bytes +16.3% (***), readable-read byob +9.1%
(***), all other rows neutral. Profiler harness: parked byte iteration
+14%, buffered byte iteration +37%, buffered byte read loop +18%,
default-stream rows at parity. WPT streams/compression/encoding
subtests identical to baseline.
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/64291
Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Consolidate the spec's per-chunk predicate chains (CanCloseOrEnqueue,
IsLocked, HasDefaultReader, GetNumReadRequests, GetDesiredSize and the
writable-side equivalents) into single passes over the controller and
stream state, mirror "close queued or in flight" as a boolean flag
maintained at the few close-request transition sites, and materialize
the TransformStream [[backpressureChangePromise]] record lazily on
first observation so backpressure flips nobody is waiting on allocate
nothing.
Assisted-by: Claude Code
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/64252
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Writable.toWeb() used a plain { highWaterMark } as the queuing
strategy for non-object-mode streams, which meant no size function
was provided. The WritableStream defaulted to counting each chunk
as size 1, so desiredSize was incorrect (e.g., a 3-byte Uint8Array
was counted as size 1 instead of 3).
Add a size() function to the strategy that mirrors how the Node.js
stream computes chunk length: byteLength for typed arrays, length
for strings/buffers, falling back to 1.
Fixes: https://github.com/nodejs/node/issues/56269
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/62986
Reviewed-By: René <contact.9a5d6388@renegade334.me.uk>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Pure-JavaScript optimizations to lib/internal/webstreams/* that reduce
per-chunk and per-construction allocations on hot paths without
observable behavior change.
Per-chunk: reuse promise-reaction closures per controller, add buffered
fast path for async iterator, specialize callback wrappers by arity,
and share immutable nil records for writable stream resets.
Per-construction: use queueMicrotask for non-object start results,
materialize reader/writer .closed and .ready records lazily, and remove
dead allocations.
Assisted-by: Claude Fable 5
Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/63876
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Mattias Buelens <mattias@buelens.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
A race condition in the Writable.toWeb() adapter caused the stream
to hang if the underlying Node.js Writable emitted a 'drain' event
synchronously during a write() call. This often happened when
highWaterMark was set to 0.
By checking writableNeedDrain immediately after a backpressured write,
the adapter now correctly detects if the stream has already drained,
resolving the backpressure promise instead of waiting indefinitely
for an event that has already occurred.
Fixes: https://github.com/nodejs/node/issues/61145
Signed-off-by: sangwook <rewq5991@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/61197
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
When using Duplex.fromWeb() or Writable.fromWeb() with cork()/uncork(),
writes are batched into _writev(). If destroy() is called in the same
microtask, the underlying WritableStream writer gets aborted, causing
SafePromiseAll() to reject with a non-array value (e.g. an AbortError).
The done() callback in _writev() of both fromWeb adapter functions
unconditionally called error.filter(), assuming the value was always an
array. This caused a TypeError that became an unhandled rejection,
crashing the process.
Fix by separating the resolve and reject handlers of SafePromiseAll:
use () => done() on the resolve path (all writes succeeded, no error)
and done on the reject path (error passed directly to callback).
Fixes: https://github.com/nodejs/node/issues/62199
Signed-off-by: sangwook <rewq5991@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/62297
Reviewed-By: René <contact.9a5d6388@renegade334.me.uk>
Convert brotli decompression errors to TypeError to match the
Compression Streams spec, by extending handleKnownInternalErrors()
in the adapters layer to recognize brotli error codes.
This replaces the manual error event handler on DecompressionStream
which was redundant with the adapter's built-in error propagation.
PR-URL: https://github.com/nodejs/node/pull/62107
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Mattias Buelens <mattias@buelens.com>
Reviewed-By: René <contact.9a5d6388@renegade334.me.uk>
Pass rejectGarbageAfterEnd: true to createInflateRaw() and
createBrotliDecompress(), matching the behavior already in place
for deflate and gzip. The Compression Streams spec treats any
data following a valid compressed payload as an error.
When the underlying Node.js stream throws synchronously from
write() (e.g. zlib rejects an invalid chunk type), destroy the
stream so that the readable side is also errored. Without this,
the readable side hangs forever waiting for data that will never
arrive.
Introduce a kValidateChunk callback option in the webstreams
adapter layer. Compression streams use this to validate that
chunks are BufferSource instances not backed by SharedArrayBuffer,
replacing the previous monkey-patching of the underlying
handle's write method.
Unskip WPT compression bad-chunks tests which now run instead of
hang and mark the remaining expected failures.
PR-URL: https://github.com/nodejs/node/pull/62107
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Mattias Buelens <mattias@buelens.com>
Reviewed-By: René <contact.9a5d6388@renegade334.me.uk>
The WHATWG Streams spec requires that pipeTo's chunk handling must
queue a microtask before calling the write algorithm. This ensures
that enqueue() does not synchronously trigger writes.
Previously, PipeToReadableStreamReadRequest[kChunk] would synchronously
call writableStreamDefaultWriterWrite(), which violated the spec and
caused the WPT test "enqueue() must not synchronously call write
algorithm" to fail.
Fix by wrapping the write operation in queueMicrotask(), which defers
it to the next microtask as required by the spec.
Refs: https://github.com/whatwg/streams/issues/1243
PR-URL: https://github.com/nodejs/node/pull/61800
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Mattias Buelens <mattias@buelens.com>
Add internal fast paths to improve webstreams performance without
changing the public API or breaking spec compliance.
1. ReadableStreamDefaultReader.read() fast path:
When data is already buffered in the controller's queue, return
PromiseResolve() directly without creating a DefaultReadRequest
object. This is spec-compliant because read() returns a Promise,
and resolved promises still run callbacks in the microtask queue.
2. pipeTo() batch read fast path:
When data is buffered, batch reads directly from the controller
queue up to highWaterMark without creating
PipeToReadableStreamReadRequest objects per chunk. Respects
backpressure by checking desiredSize after each write.
Benchmark results:
- pipeTo: ~11% faster (***)
- buffered read(): ~17-20% faster (***)
Co-Authored-By: Malte Ubl <malte@vercel.com>
PR-URL: https://github.com/nodejs/node/pull/61807
Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>