mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
Short-lived streams (create, a few chunks, close) pay a fixed cost per stream that dominates once the per-chunk path is lean. Streams created internally (transform stream sides, tee branches, ReadableStream.from, transferred streams) were built by wrapper constructors that swapped the prototype of every instance and then assigned an own, enumerable `constructor` property to look like a public stream. Each internal stream therefore had its own hidden class and `Object.keys(stream)` reported `['constructor']`. The public constructors now accept the internal construction sentinel and leave controller setup to the caller, so every ReadableStream and WritableStream shares one hidden class and no per-instance prototype swap or own property is needed. The queue ring buffer grew after a push filled it, so the initial 8-slot ring held only three (value, size) pairs and a four-chunk stream reallocated every time. Growing before the push lets the ring hold four pairs. pipeTo observed the source's closed promise with two reactions and, on teardown, let the reader and writer release paths probe and reject promise records that only the pipe could have observed. One reaction pair now watches the source, and finalize drops the records before release. Signed-off-by: Matteo Collina <hello@matteocollina.com> PR-URL: https://github.com/nodejs/node/pull/66052 Reviewed-By: Paolo Insogna <paolo@cowtech.it> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
487 lines
14 KiB
JavaScript
487 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
Array,
|
|
ArrayBufferPrototypeGetByteLength,
|
|
ArrayBufferPrototypeGetDetached,
|
|
ArrayBufferPrototypeSlice,
|
|
AsyncIteratorPrototype,
|
|
DataViewPrototypeGetBuffer,
|
|
DataViewPrototypeGetByteLength,
|
|
DataViewPrototypeGetByteOffset,
|
|
FunctionPrototypeCall,
|
|
MathMax,
|
|
NumberIsNaN,
|
|
ObjectFreeze,
|
|
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 AsyncIterator = {
|
|
__proto__: AsyncIteratorPrototype,
|
|
next: undefined,
|
|
return: undefined,
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
function setPromiseHandled(promise) {
|
|
// 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,
|
|
AsyncIterator,
|
|
Queue,
|
|
canCopyArrayBuffer,
|
|
cloneAsUint8Array,
|
|
copyArrayBuffer,
|
|
createPromiseCallbackNoParams,
|
|
createPromiseCallback1Param,
|
|
createPromiseCallback2Params,
|
|
createRawCallback1Param,
|
|
createRawCallback2Params,
|
|
customInspect,
|
|
defaultSizeAlgorithm,
|
|
dequeueValue,
|
|
enqueueValueWithSize,
|
|
extractHighWaterMark,
|
|
extractSizeAlgorithm,
|
|
getNonWritablePropertyDescriptor,
|
|
isBrandCheck,
|
|
isPromisePending,
|
|
kEmptyQueue,
|
|
kParkedAlgorithmResult,
|
|
kResolvedPromise,
|
|
kState,
|
|
kType,
|
|
lazyTransfer,
|
|
materializeQueue,
|
|
nonOpCallback,
|
|
nonOpCancel,
|
|
nonOpFlush,
|
|
|
|
peekQueueValue,
|
|
rejectedHandledRecord,
|
|
resetQueue,
|
|
resolvedRecord,
|
|
setPromiseHandled,
|
|
thenAlgorithmResult,
|
|
};
|