Files
Matteo Collina 4e75b714df stream: trim TransformStream construction costs
Constructing a TransformStream allocated five closures for the sink and
source algorithms of its two sides, and each side adopted the start
promise through a wrapper promise plus a thenable job, so a stream
without a start() left about three kilobytes pending in the microtask
queue until the started steps ran. Per-stream creation bursts spend
most of their time copying that graph through the scavenger.

The sink and source algorithms are now shared functions that reach the
transform stream through a field on their controller state (the
controller is passed to the close, abort and cancel algorithms for
that), and the post-start steps of both sides are delivered by one
reaction chain on a shared promise, taking the same microtask hops as
the spec's start promise adoption: three after construction for a
non-thenable start result, two after the adopting promise settles for a
thenable one. The readable and writable transfer state records are
materialized on first transfer instead of per stream.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: https://github.com/nodejs/node/pull/66643
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Mattias Buelens <mattias@buelens.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
2026-10-17 06:20:05 +00:00

501 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');
// Start algorithm of internal streams whose creator delivers the
// post-start step itself (see transformStreamStart): the controller setup
// leaves `started` unset when the start result is this sentinel.
const kDeferredStart = Symbol('kDeferredStart');
function deferredStartAlgorithm() {
return kDeferredStart;
}
// 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,
deferredStartAlgorithm,
dequeueValue,
enqueueValueWithSize,
extractHighWaterMark,
extractSizeAlgorithm,
getNonWritablePropertyDescriptor,
isBrandCheck,
isPromisePending,
kDeferredStart,
kEmptyQueue,
kParkedAlgorithmResult,
kPendingPromise,
kResolvedPromise,
kState,
kType,
lazyTransfer,
materializeQueue,
nonOpCallback,
nonOpCancel,
nonOpFlush,
peekQueueValue,
rejectedHandledRecord,
resetQueue,
resolvedRecord,
setPromiseHandled,
thenAlgorithmResult,
};