Files
node/lib/internal/webstreams/util.js
T
Matteo Collina 29bd7402f8 stream: skip idle webstreams start and watchers
A source without pull() has nothing observable left to do in its
post-start step, since the started flag only gates calls into the pull
algorithm. Set the flag right away instead of from a microtask, so a
push-style ReadableStream allocates neither the closure nor the task
and is not kept alive until the next microtask checkpoint.

pipeTo and tee hold the only references to their reader and writer, so
their [[closedPromise]] records are never observed as promises. Install
the watchers as the records themselves, as pipeTo's ready hook already
does, instead of materializing a promise plus reaction per side, and
hand the erroring/release probes one shared pending promise. The tee's
cancel promise is likewise materialized by the first branch cancel.

Microtask ordering is unchanged: each hook enqueues its watcher at the
position the promise reaction would have had.

node benchmark/compare.js --runs 20 over benchmark/webstreams (46 rows,
all others within the confidence interval):

webstreams/creation.js kind='ReadableStream'               ***  +172.40%
webstreams/creation.js kind='ReadableStream.tee'           ***   +20.47%
webstreams/creation.js kind='ReadableStreamBYOBReader'     ***   +15.66%
webstreams/creation.js kind='ReadableStreamDefaultReader'  ***   +14.53%
webstreams/lifecycle.js kind='pipe-to' (40 runs)           **    +10.98%

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/66424
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: James M Snell <jasnell@gmail.com>
2026-10-13 10:10:59 +02:00

490 lines
15 KiB
JavaScript

'use strict';
const {
Array,
ArrayBufferPrototypeGetByteLength,
ArrayBufferPrototypeGetDetached,
ArrayBufferPrototypeSlice,
DataViewPrototypeGetBuffer,
DataViewPrototypeGetByteLength,
DataViewPrototypeGetByteOffset,
FunctionPrototypeCall,
MathMax,
NumberIsNaN,
ObjectFreeze,
Promise,
PromisePrototypeThen,
PromiseReject,
PromiseResolve,
Symbol,
TypedArrayPrototypeGetBuffer,
TypedArrayPrototypeGetByteLength,
TypedArrayPrototypeGetByteOffset,
Uint8Array,
} = primordials;
const {
codes: {
ERR_INVALID_ARG_VALUE,
},
} = require('internal/errors');
const {
copyArrayBuffer,
} = internalBinding('buffer');
const {
inspect,
} = require('util');
const {
constants: {
kPending,
},
getPromiseDetails,
} = internalBinding('util');
const assert = require('internal/assert');
const {
isDataView,
} = require('internal/util/types');
const {
validateFunction,
} = require('internal/validators');
const kState = Symbol('kState');
const kType = Symbol('kType');
const getNonWritablePropertyDescriptor = (value) => {
return {
__proto__: null,
configurable: true,
value,
};
};
function extractHighWaterMark(value, defaultHWM) {
if (value === undefined) return defaultHWM;
const coercedValue = +value;
if (NumberIsNaN(coercedValue) ||
coercedValue < 0)
throw new ERR_INVALID_ARG_VALUE.RangeError('strategy.highWaterMark', value);
return coercedValue;
}
// The default size algorithm is never exposed to user code, so a single
// shared function avoids one closure allocation per stream.
const defaultSizeAlgorithm = () => 1;
function extractSizeAlgorithm(size) {
if (size === undefined) return defaultSizeAlgorithm;
validateFunction(size, 'strategy.size');
return size;
}
function customInspect(depth, options, name, data) {
if (depth < 0)
return this;
const opts = {
...options,
depth: options.depth == null ? null : options.depth - 1,
};
return `${name} ${inspect(data, opts)}`;
}
// These use the original prototype getters so that user tampering with
// the buffer, byteLength, and byteOffset properties on ArrayBuffer and
// ArrayBufferView's is not observed. They run once or more per chunk on
// every byte-stream path, so they must not go through a reflective get
// (the previous view.constructor.prototype lookup was both slower and
// spoofable via a user-defined .constructor).
function ArrayBufferViewGetBuffer(view) {
return isDataView(view) ?
DataViewPrototypeGetBuffer(view) :
TypedArrayPrototypeGetBuffer(view);
}
function ArrayBufferViewGetByteLength(view) {
return isDataView(view) ?
DataViewPrototypeGetByteLength(view) :
TypedArrayPrototypeGetByteLength(view);
}
function ArrayBufferViewGetByteOffset(view) {
return isDataView(view) ?
DataViewPrototypeGetByteOffset(view) :
TypedArrayPrototypeGetByteOffset(view);
}
function cloneAsUint8Array(view) {
const buffer = ArrayBufferViewGetBuffer(view);
const byteOffset = ArrayBufferViewGetByteOffset(view);
const byteLength = ArrayBufferViewGetByteLength(view);
return new Uint8Array(
ArrayBufferPrototypeSlice(buffer, byteOffset, byteOffset + byteLength),
);
}
function canCopyArrayBuffer(toBuffer, toIndex, fromBuffer, fromIndex, count) {
return toBuffer !== fromBuffer &&
!ArrayBufferPrototypeGetDetached(toBuffer) &&
!ArrayBufferPrototypeGetDetached(fromBuffer) &&
toIndex + count <= ArrayBufferPrototypeGetByteLength(toBuffer) &&
fromIndex + count <= ArrayBufferPrototypeGetByteLength(fromBuffer);
}
function isBrandCheck(brand) {
return (value) => {
return value != null &&
value[kState] !== undefined &&
value[kType] === brand;
};
}
// Backing store for the spec's [[queue]]: a power-of-two ring buffer
// instead of a plain array. Entries are pushed at the tail and consumed
// at the head, so a plain array either moves every element or forces the
// engine to re-linearize on each shift, and the default controllers would
// additionally have to allocate a { value, size } wrapper object per
// chunk just to keep the pair together. Default readable/writable
// controller queues store each entry as (value, size) in two consecutive
// slots via the *Pair methods; the readable byte controller queue stores
// its chunk descriptor records in single slots via push/shift/peek. A
// given instance only ever uses one of the two access patterns, so
// head/tail stay aligned to the entry stride.
class Queue {
constructor(listLength = 8) {
this.head = 0;
this.tail = 0;
// Number of logical entries currently in the queue: (value, size)
// pairs for default controller queues, descriptor records for byte
// controller queues.
this.length = 0;
this.capacityMask = listLength - 1;
this.list = new Array(listLength);
this.dequeuedSize = 0;
}
// Single-slot entries (readable byte controller chunk records).
push(entry) {
if (this.length === this.list.length)
this.grow();
const tail = this.tail;
this.list[tail] = entry;
this.tail = (tail + 1) & this.capacityMask;
this.length++;
}
shift() {
const head = this.head;
const list = this.list;
const entry = list[head];
list[head] = undefined;
this.head = (head + 1) & this.capacityMask;
if (--this.length === 0)
this.rewind();
return entry;
}
peek() {
return this.list[this.head];
}
// Two-slot (value, size) entries (default controller queues). The
// stride is always 2 and capacities are even, so `tail + 1`/`head + 1`
// never need to wrap.
pushPair(value, size) {
if (this.length * 2 === this.list.length)
this.grow();
const tail = this.tail;
const list = this.list;
list[tail] = value;
list[tail + 1] = size;
this.tail = (tail + 2) & this.capacityMask;
this.length++;
}
// Returns the dequeued value; the size of the same entry is left in
// `this.dequeuedSize` so that callers can update [[queueTotalSize]]
// without a per-entry wrapper object having to exist.
shiftPair() {
const head = this.head;
const list = this.list;
const value = list[head];
this.dequeuedSize = list[head + 1];
list[head] = undefined;
list[head + 1] = undefined;
this.head = (head + 2) & this.capacityMask;
if (--this.length === 0)
this.rewind();
return value;
}
peekPairValue() {
return this.list[this.head];
}
// The ring is completely full (the tail has caught up with the head, so
// the next push would overwrite the oldest entry): double the capacity,
// re-linearizing from the head so index arithmetic stays trivial.
// Growing before the push rather than after it lets the initial 8-slot
// ring hold four (value, size) pairs without reallocating.
grow() {
const list = this.list;
const capacity = list.length;
const head = this.head;
if (head !== 0) {
const relinearized = new Array(capacity * 2);
let n = 0;
for (let i = head; i < capacity; i++)
relinearized[n++] = list[i];
for (let i = 0; i < head; i++)
relinearized[n++] = list[i];
this.list = relinearized;
this.head = 0;
} else {
list.length = capacity * 2;
}
this.tail = capacity;
this.capacityMask = (capacity * 2) - 1;
}
// The queue just became empty: restart at slot 0 so shallow queues net
// sequential slot access, and drop the enlarged backing store after a
// large burst has fully drained.
rewind() {
this.head = 0;
this.tail = 0;
if (this.list.length > 1024) {
this.list.length = 8;
this.capacityMask = 0b111;
}
}
}
// Controllers start out with (and are reset to) this shared immutable
// empty queue, so constructing a stream never allocates queue storage;
// a real Queue is materialized by the enqueue paths on first use. All
// dequeue/peek paths are guarded by `.length` (or the equivalent
// [[queueTotalSize]]) checks, so they can never observe the sentinel in
// a mutating way; it never stores entries, so it gets a zero-length
// backing list.
const kEmptyQueue = ObjectFreeze(new Queue(0));
function materializeQueue(state) {
const queue = state.queue;
if (queue === kEmptyQueue)
return state.queue = new Queue();
return queue;
}
// The queue helpers below run once per chunk on the hot paths of every
// default readable/writable stream, so they load the controller state a
// single time and don't assert the existence of the queue fields (both
// are unconditionally initialized during controller setup and only ever
// replaced wholesale).
function dequeueValue(controller) {
const state = controller[kState];
const queue = state.queue;
assert(queue.length);
const value = queue.shiftPair();
state.queueTotalSize = MathMax(0, state.queueTotalSize - queue.dequeuedSize);
return value;
}
function resetQueue(controller) {
const state = controller[kState];
state.queue = kEmptyQueue;
state.queueTotalSize = 0;
}
function peekQueueValue(controller) {
const state = controller[kState];
assert(state.queue.length);
return state.queue.peekPairValue();
}
function enqueueValueWithSize(controller, value, size) {
const state = controller[kState];
const coercedSize = +size;
if (NumberIsNaN(coercedSize) ||
coercedSize < 0 ||
coercedSize === Infinity) {
throw new ERR_INVALID_ARG_VALUE.RangeError('size', size);
}
materializeQueue(state).pushPair(value, coercedSize);
state.queueTotalSize += coercedSize;
}
// Arity-specialized variants of the promise-callback wrapper. The generic
// rest-parameter + ReflectApply form allocated an arguments array on every
// invocation; these run on per-chunk hot paths (pull/write/transform), so
// each known call-site arity gets its own wrapper. The exact number of
// arguments passed through to the user callback is observable and must be
// preserved.
function createPromiseCallbackNoParams(name, fn, thisArg) {
validateFunction(fn, name);
return async () => FunctionPrototypeCall(fn, thisArg);
}
// Raw variants that skip the async wrapper's implicit result promise.
// Consumers of a raw callback invoke it inside try/catch and route the
// result through thenAlgorithmResult() below.
function createRawCallback1Param(name, fn, thisArg) {
validateFunction(fn, name);
return (arg) => FunctionPrototypeCall(fn, thisArg, arg);
}
function createRawCallback2Params(name, fn, thisArg) {
validateFunction(fn, name);
return (arg1, arg2) => FunctionPrototypeCall(fn, thisArg, arg1, arg2);
}
// A single shared, forever-resolved promise used to enqueue a reaction at
// the next microtask checkpoint without allocating a fresh promise.
const kResolvedPromise = PromiseResolve();
// Returned by an internal algorithm to signal that it parked the
// operation and takes responsibility for delivering the fulfilled (or
// rejected) continuation itself later, instead of settling a promise
// (see the transform stream source pull algorithm).
const kParkedAlgorithmResult = Symbol('kParkedAlgorithmResult');
// Wires the (possibly non-thenable) result of an underlying algorithm
// callback to its fulfilled/rejected continuations. A non-thenable result
// means fulfillment is guaranteed and no then() lookup is observable, so
// the fulfillment step is enqueued directly at the exact microtask
// position the coerced promise's reaction would have had, skipping the
// per-chunk promise allocation. For thenable results PromiseResolve()
// matches the spec's "a promise resolved with" conversion (identity for
// native promises).
function thenAlgorithmResult(result, onFulfilled, onRejected) {
if (result === kParkedAlgorithmResult)
return;
if (result === null ||
(typeof result !== 'object' && typeof result !== 'function')) {
PromisePrototypeThen(kResolvedPromise, onFulfilled);
} else {
PromisePrototypeThen(PromiseResolve(result), onFulfilled, onRejected);
}
}
function createPromiseCallback1Param(name, fn, thisArg) {
validateFunction(fn, name);
return async (arg) => FunctionPrototypeCall(fn, thisArg, arg);
}
function createPromiseCallback2Params(name, fn, thisArg) {
validateFunction(fn, name);
return async (arg1, arg2) => FunctionPrototypeCall(fn, thisArg, arg1, arg2);
}
function isPromisePending(promise) {
if (promise === undefined) return false;
const details = getPromiseDetails(promise);
return details?.[0] === kPending;
}
// Shared shapes for lazily-materialized { promise, resolve, reject }
// records whose settlement is already known.
function resolvedRecord() {
return {
promise: PromiseResolve(),
resolve: undefined,
reject: undefined,
};
}
function rejectedHandledRecord(error) {
const record = {
promise: PromiseReject(error),
resolve: undefined,
reject: undefined,
};
setPromiseHandled(record.promise);
return record;
}
// A single shared, forever-pending promise carried by the duck-typed
// promise records that pipeTo and tee install on their internal reader
// and writer (see ClosedPromiseHook in readablestream.js): the probes on
// the erroring/release paths see a pending promise, and setPromiseHandled
// skips it so that no reaction ever accumulates on it.
const kPendingPromise = new Promise(() => {});
function setPromiseHandled(promise) {
if (promise === kPendingPromise)
return;
// Alternatively, we could use the native API
// MarkAsHandled, but this avoids the extra boundary cross
// and is hopefully faster at the cost of an extra Promise
// allocation.
PromisePrototypeThen(promise, undefined, () => {});
}
async function nonOpFlush() {}
// Shared non-op for the start/pull/write algorithm callbacks, which all
// follow the raw-callback contract (see createRawCallback*): the
// non-thenable return takes the allocation-free fast path in
// thenAlgorithmResult().
function nonOpCallback() {}
async function nonOpCancel() {}
let transfer;
function lazyTransfer() {
if (transfer === undefined)
transfer = require('internal/webstreams/transfer');
return transfer;
}
module.exports = {
ArrayBufferViewGetBuffer,
ArrayBufferViewGetByteLength,
ArrayBufferViewGetByteOffset,
Queue,
canCopyArrayBuffer,
cloneAsUint8Array,
copyArrayBuffer,
createPromiseCallbackNoParams,
createPromiseCallback1Param,
createPromiseCallback2Params,
createRawCallback1Param,
createRawCallback2Params,
customInspect,
defaultSizeAlgorithm,
dequeueValue,
enqueueValueWithSize,
extractHighWaterMark,
extractSizeAlgorithm,
getNonWritablePropertyDescriptor,
isBrandCheck,
isPromisePending,
kEmptyQueue,
kParkedAlgorithmResult,
kPendingPromise,
kResolvedPromise,
kState,
kType,
lazyTransfer,
materializeQueue,
nonOpCallback,
nonOpCancel,
nonOpFlush,
peekQueueValue,
rejectedHandledRecord,
resetQueue,
resolvedRecord,
setPromiseHandled,
thenAlgorithmResult,
};