Files
node/lib/internal/streams/iter/utils.js
T
Trivikram Kamat 7bee75438f stream: abort pending single-source merge reads
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>
2026-07-24 05:08:09 +00:00

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,
};