mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
Make the single-source merge path abort-aware so signal cancellation rejects a pending read and closes the active source iterator. Handle synchronous iterator return values when marking aborted cleanup as handled. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64445 Fixes: https://github.com/nodejs/node/issues/64444 Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>
398 lines
10 KiB
JavaScript
398 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
Array,
|
|
ArrayBufferPrototypeGetByteLength,
|
|
ArrayPrototypeSlice,
|
|
PromiseResolve,
|
|
PromiseWithResolvers,
|
|
SafePromisePrototypeFinally,
|
|
SafePromiseRace,
|
|
String,
|
|
SymbolAsyncIterator,
|
|
TypedArrayPrototypeGetBuffer,
|
|
TypedArrayPrototypeGetByteLength,
|
|
TypedArrayPrototypeGetByteOffset,
|
|
TypedArrayPrototypeSet,
|
|
Uint8Array,
|
|
} = primordials;
|
|
|
|
const {
|
|
markPromiseAsHandled,
|
|
} = internalBinding('util');
|
|
|
|
const { TextEncoder } = require('internal/encoding');
|
|
const {
|
|
codes: {
|
|
ERR_INVALID_ARG_TYPE,
|
|
ERR_OPERATION_FAILED,
|
|
},
|
|
} = require('internal/errors');
|
|
const { isError } = require('internal/util');
|
|
|
|
const { isSharedArrayBuffer, isUint8Array } = require('internal/util/types');
|
|
|
|
const {
|
|
validateAbortSignal,
|
|
validateOneOf,
|
|
} = require('internal/validators');
|
|
|
|
// Cached resolved promise to avoid allocating a new one on every sync fast-path.
|
|
const kResolvedPromise = PromiseResolve();
|
|
|
|
// Shared TextEncoder instance for string conversion.
|
|
const encoder = new TextEncoder();
|
|
|
|
// Default high water marks for push and multi-consumer streams. These values
|
|
// are somewhat arbitrary but have been tested across various workloads and
|
|
// appear to yield the best overall throughput/latency balance.
|
|
|
|
/** Minimum and default byte budget for push streams (single-consumer). */
|
|
const kPushDefaultBudget = 16384;
|
|
|
|
/** Default byte budget for broadcast and share streams (multi-consumer). */
|
|
const kMultiConsumerDefaultBudget = 65536;
|
|
|
|
/**
|
|
* Register a handler for an AbortSignal, handling the already-aborted case.
|
|
* If the signal is already aborted, calls handler immediately.
|
|
* Otherwise, adds a one-time 'abort' listener.
|
|
* @param {AbortSignal} signal
|
|
* @param {Function} handler
|
|
*/
|
|
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<Uint8Array[]>>|IteratorResult<Uint8Array[]>}
|
|
*/
|
|
function abortableNext(iterator, signal) {
|
|
if (signal === undefined) {
|
|
return iterator.next();
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wrap an async source so each pending read is abort-aware.
|
|
* @param {AsyncIterable<Uint8Array[]>} source - The source to read from.
|
|
* @param {AbortSignal|undefined} signal - Optional abort signal.
|
|
* @returns {AsyncIterable<Uint8Array[]>}
|
|
*/
|
|
function yieldAbortable(source, signal) {
|
|
if (signal === undefined) {
|
|
return source;
|
|
}
|
|
|
|
return {
|
|
__proto__: null,
|
|
async *[SymbolAsyncIterator]() {
|
|
const iterator = source[SymbolAsyncIterator]();
|
|
let completed = false;
|
|
let aborted = false;
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await abortableNext(iterator, signal);
|
|
if (done) {
|
|
completed = true;
|
|
return;
|
|
}
|
|
signal.throwIfAborted();
|
|
yield value;
|
|
}
|
|
} 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;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compute the minimum cursor across a set of consumers and count how many
|
|
* consumers are at that cursor.
|
|
* @param {Set} consumers - Set of objects with a `cursor` property
|
|
* @param {number} fallback - Cursor to return when set is empty
|
|
* @returns {{ minCursor: number, minCursorConsumers: number }}
|
|
*/
|
|
function getMinCursor(consumers, fallback) {
|
|
let minCursor = fallback;
|
|
let minCursorConsumers = 0;
|
|
for (const consumer of consumers) {
|
|
if (consumer.cursor < minCursor) {
|
|
minCursor = consumer.cursor;
|
|
minCursorConsumers = 1;
|
|
} else if (consumer.cursor === minCursor) {
|
|
minCursorConsumers++;
|
|
}
|
|
}
|
|
return { __proto__: null, minCursor, minCursorConsumers };
|
|
}
|
|
|
|
/**
|
|
* Convert a chunk (string or Uint8Array) to Uint8Array.
|
|
* Strings are UTF-8 encoded.
|
|
* @param {Uint8Array|string} chunk
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function toUint8Array(chunk) {
|
|
if (typeof chunk === 'string') {
|
|
return encoder.encode(chunk);
|
|
}
|
|
if (!isUint8Array(chunk)) {
|
|
throw new ERR_INVALID_ARG_TYPE('chunk', ['string', 'Uint8Array'], chunk);
|
|
}
|
|
return chunk;
|
|
}
|
|
|
|
/**
|
|
* Check if all chunks in an array are already Uint8Array.
|
|
* Short-circuits on the first non-Uint8Array chunk found.
|
|
* @param {Array<Uint8Array|string>} chunks
|
|
* @returns {boolean}
|
|
*/
|
|
function allUint8Array(chunks) {
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
if (!isUint8Array(chunks[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function copyBytes(chunk) {
|
|
const copy = new Uint8Array(TypedArrayPrototypeGetByteLength(chunk));
|
|
TypedArrayPrototypeSet(copy, chunk);
|
|
return copy;
|
|
}
|
|
|
|
/**
|
|
* Concatenate multiple Uint8Arrays into a single Uint8Array.
|
|
* @param {Uint8Array[]} chunks
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function concatBytes(chunks) {
|
|
// Empty stream: return zero-length Uint8Array
|
|
if (chunks.length === 0) {
|
|
return new Uint8Array(0);
|
|
}
|
|
// Single chunk: return directly if it covers the entire backing buffer,
|
|
// otherwise return a copy
|
|
if (chunks.length === 1) {
|
|
const chunk = chunks[0];
|
|
// If non-zero offset, skip the remaining buffer checks.
|
|
if (TypedArrayPrototypeGetByteOffset(chunk) === 0) {
|
|
const buf = TypedArrayPrototypeGetBuffer(chunk);
|
|
if (
|
|
!isSharedArrayBuffer(buf) &&
|
|
TypedArrayPrototypeGetByteLength(chunk) ===
|
|
ArrayBufferPrototypeGetByteLength(buf)
|
|
) {
|
|
return chunk;
|
|
}
|
|
}
|
|
return copyBytes(chunk);
|
|
}
|
|
// Multiple chunks: concatenate
|
|
let totalByteLength = 0;
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
totalByteLength += TypedArrayPrototypeGetByteLength(chunks[i]);
|
|
}
|
|
const concatenated = new Uint8Array(totalByteLength);
|
|
let offset = 0;
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
TypedArrayPrototypeSet(concatenated, chunks[i], offset);
|
|
offset += TypedArrayPrototypeGetByteLength(chunks[i]);
|
|
}
|
|
return concatenated;
|
|
}
|
|
|
|
/**
|
|
* Convert an array of chunks (strings or Uint8Arrays) to a Uint8Array[].
|
|
* Always returns a fresh copy of the array.
|
|
* @param {Array<Uint8Array|string>} chunks
|
|
* @returns {Uint8Array[]}
|
|
*/
|
|
function convertChunks(chunks) {
|
|
if (allUint8Array(chunks)) {
|
|
return ArrayPrototypeSlice(chunks);
|
|
}
|
|
const len = chunks.length;
|
|
const result = new Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
result[i] = toUint8Array(chunks[i]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Validate Writer options and return options.signal.
|
|
* @param {object|undefined} options
|
|
* @returns {AbortSignal|undefined}
|
|
*/
|
|
function getWriterSignal(options) {
|
|
const signal = options?.signal;
|
|
validateAbortSignal(signal, 'options.signal');
|
|
return signal;
|
|
}
|
|
|
|
/**
|
|
* Wrap a caught value as an Error, converting non-Error values.
|
|
* @param {unknown} error
|
|
* @returns {Error}
|
|
*/
|
|
function wrapError(error) {
|
|
return isError(error) ? error : new ERR_OPERATION_FAILED(String(error));
|
|
}
|
|
|
|
/**
|
|
* Check if a value implements a Symbol-keyed protocol (has a function
|
|
* at the given symbol key).
|
|
* @param {unknown} value
|
|
* @param {symbol} symbol
|
|
* @returns {boolean}
|
|
*/
|
|
function hasProtocol(value, symbol) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
symbol in value &&
|
|
typeof value[symbol] === 'function'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if a value is PullOptions (object without transform or write property).
|
|
* @param {unknown} value
|
|
* @returns {boolean}
|
|
*/
|
|
function isPullOptions(value) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!('transform' in value) &&
|
|
!('write' in value)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if a value is a stateful transform object (has a transform method).
|
|
* @param {unknown} value
|
|
* @returns {boolean}
|
|
*/
|
|
function isTransformObject(value) {
|
|
return typeof value?.transform === 'function';
|
|
}
|
|
|
|
/**
|
|
* Check if a value is a valid transform (function or transform object).
|
|
* @param {unknown} value
|
|
* @returns {boolean}
|
|
*/
|
|
function isTransform(value) {
|
|
return typeof value === 'function' || isTransformObject(value);
|
|
}
|
|
|
|
/**
|
|
* Parse variadic arguments for pull/pullSync.
|
|
* Returns { transforms, options }
|
|
* @param {Array} args
|
|
* @returns {{ transforms: Array, options: object|undefined }}
|
|
*/
|
|
function parsePullArgs(args) {
|
|
if (args.length === 0) {
|
|
return { __proto__: null, transforms: [], options: undefined };
|
|
}
|
|
|
|
let transforms;
|
|
let options;
|
|
const last = args[args.length - 1];
|
|
if (isPullOptions(last)) {
|
|
transforms = ArrayPrototypeSlice(args, 0, -1);
|
|
options = last;
|
|
} else {
|
|
transforms = args;
|
|
options = undefined;
|
|
}
|
|
|
|
for (let i = 0; i < transforms.length; i++) {
|
|
if (!isTransform(transforms[i])) {
|
|
throw new ERR_INVALID_ARG_TYPE(
|
|
`transforms[${i}]`, ['Function', 'Object with transform()'],
|
|
transforms[i]);
|
|
}
|
|
}
|
|
|
|
return { __proto__: null, transforms, options };
|
|
}
|
|
|
|
/**
|
|
* Validate backpressure option value.
|
|
* @param {string} value
|
|
*/
|
|
function validateBackpressure(value) {
|
|
validateOneOf(value, 'options.backpressure', [
|
|
'strict',
|
|
'unbounded',
|
|
'drop-oldest',
|
|
'drop-newest',
|
|
]);
|
|
}
|
|
|
|
module.exports = {
|
|
kMultiConsumerDefaultBudget,
|
|
kPushDefaultBudget,
|
|
kResolvedPromise,
|
|
allUint8Array,
|
|
concatBytes,
|
|
convertChunks,
|
|
getWriterSignal,
|
|
getMinCursor,
|
|
hasProtocol,
|
|
isPullOptions,
|
|
isTransform,
|
|
isTransformObject,
|
|
onSignalAbort,
|
|
parsePullArgs,
|
|
toUint8Array,
|
|
validateBackpressure,
|
|
wrapError,
|
|
yieldAbortable,
|
|
};
|