Commit 6fb199ab3dd for nodejs
commit 6fb199ab3ddfea1172c74fd746c394fa6838d3a9
Author: James M Snell <jasnell@gmail.com>
Date: Sun Aug 30 16:34:29 2026 +0000
stream: ensure nested async iterators are cancellation aware
Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Opencode
PR-URL: https://github.com/nodejs/node/pull/66030
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
diff --git a/lib/internal/streams/iter/from.js b/lib/internal/streams/iter/from.js
index 7fe03511861..cb0fd5358a0 100644
--- a/lib/internal/streams/iter/from.js
+++ b/lib/internal/streams/iter/from.js
@@ -16,6 +16,10 @@ const {
DataViewPrototypeGetByteOffset,
FunctionPrototypeCall,
PromisePrototypeThen,
+ PromiseResolve,
+ PromiseWithResolvers,
+ SafePromiseRace,
+ Symbol,
SymbolAsyncIterator,
SymbolIterator,
TypedArrayPrototypeGetBuffer,
@@ -29,8 +33,10 @@ const { markPromiseAsHandled } = internalBinding('util');
const {
codes: {
ERR_INVALID_ARG_TYPE,
+ ERR_INVALID_RETURN_VALUE,
},
} = require('internal/errors');
+const { lazyDOMException } = require('internal/util');
const {
isAnyArrayBuffer,
@@ -54,6 +60,81 @@ const {
// Bounds peak memory when arrays flow through transforms, which must
// allocate output for the entire batch at once.
const FROM_BATCH_SIZE = 128;
+const kNormalizationCancelled = Symbol('kNormalizationCancelled');
+
+function createNormalizationContext() {
+ return {
+ __proto__: null,
+ cancelled: false,
+ reason: undefined,
+ resolve: null,
+ suppressCleanup: false,
+ };
+}
+
+function cancelNormalization(context, reason, suppressCleanup = false) {
+ if (context.cancelled) return;
+ context.cancelled = true;
+ context.reason = reason;
+ context.suppressCleanup = suppressCleanup;
+ context.resolve?.(kNormalizationCancelled);
+}
+
+function throwIfNormalizationCancelled(context) {
+ if (context?.cancelled) throw context.reason;
+}
+
+async function waitForNormalization(value, context) {
+ if (context === undefined) return value;
+ const { promise, resolve } = PromiseWithResolvers();
+ if (context.cancelled) {
+ resolve(kNormalizationCancelled);
+ } else {
+ context.resolve = resolve;
+ }
+ try {
+ const result = await SafePromiseRace([
+ PromiseResolve(value),
+ promise,
+ ]);
+ throwIfNormalizationCancelled(context);
+ return result;
+ } finally {
+ if (context.resolve === resolve) context.resolve = null;
+ }
+}
+
+function createNormalizationIterator(createIterator) {
+ const context = createNormalizationContext();
+ const iterator = createIterator(context);
+ return {
+ __proto__: null,
+ next(value) {
+ return FunctionPrototypeCall(iterator.next, iterator, value);
+ },
+ return(value) {
+ cancelNormalization(
+ context, lazyDOMException('Aborted', 'AbortError'));
+ return FunctionPrototypeCall(iterator.return, iterator, value);
+ },
+ throw(error) {
+ cancelNormalization(context, error, true);
+ return FunctionPrototypeCall(iterator.throw, iterator, error);
+ },
+ [SymbolAsyncIterator]() {
+ return this;
+ },
+ };
+}
+
+function createNormalizationSource(createIterator) {
+ return {
+ __proto__: null,
+ [SymbolAsyncIterator]() {
+ return createNormalizationIterator(createIterator);
+ },
+ };
+}
// =============================================================================
// Type Guards and Detection
@@ -256,6 +337,101 @@ function* normalizeSyncSource(source) {
}
}
+function yieldNormalizationAbortable(source, context) {
+ if (context === undefined) return source;
+ return {
+ __proto__: null,
+ [SymbolAsyncIterator]() {
+ const iteratorMethod = source[SymbolAsyncIterator];
+ const iterator = FunctionPrototypeCall(iteratorMethod, source);
+ const nextMethod = iterator.next;
+ let completed = false;
+ let closed = false;
+ let reading = false;
+
+ async function closeSource(suppressError) {
+ if (closed) return;
+ closed = true;
+ completed = true;
+
+ if (suppressError) {
+ try {
+ const returnMethod = iterator.return;
+ if (typeof returnMethod === 'function') {
+ const cleanup = PromisePrototypeThen(
+ PromiseResolve(),
+ () => FunctionPrototypeCall(returnMethod, iterator));
+ markPromiseAsHandled(cleanup);
+ }
+ } catch {
+ // Cancellation has precedence over source cleanup errors.
+ }
+ return;
+ }
+
+ const returnMethod = iterator.return;
+ if (typeof returnMethod === 'function') {
+ const result = await FunctionPrototypeCall(returnMethod, iterator);
+ if ((typeof result !== 'object' && typeof result !== 'function') ||
+ result === null) {
+ throw new ERR_INVALID_RETURN_VALUE(
+ 'an object', 'iterator.return()', result);
+ }
+ }
+ }
+
+ return {
+ __proto__: null,
+ async next() {
+ if (completed) {
+ return { __proto__: null, done: true, value: undefined };
+ }
+ throwIfNormalizationCancelled(context);
+ reading = true;
+
+ try {
+ const next = FunctionPrototypeCall(nextMethod, iterator);
+ const result = await waitForNormalization(next, context);
+ if ((typeof result !== 'object' && typeof result !== 'function') ||
+ result === null) {
+ throw new ERR_INVALID_RETURN_VALUE(
+ 'an object', 'iterator.next()', result);
+ }
+ if (result.done) {
+ reading = false;
+ throwIfNormalizationCancelled(context);
+ completed = true;
+ closed = true;
+ return { __proto__: null, done: true, value: result.value };
+ }
+ const value = result.value;
+ reading = false;
+ throwIfNormalizationCancelled(context);
+ return { __proto__: null, done: false, value };
+ } catch (error) {
+ if (context.cancelled) await closeSource(true);
+ reading = false;
+ throw error;
+ }
+ },
+ async return(value) {
+ await closeSource(
+ context.suppressCleanup || (context.cancelled && reading));
+ return { __proto__: null, done: true, value };
+ },
+ async throw(error) {
+ await closeSource(
+ context.suppressCleanup || (context.cancelled && reading));
+ throw error;
+ },
+ [SymbolAsyncIterator]() {
+ return this;
+ },
+ };
+ },
+ };
+}
+
// =============================================================================
// Async Normalization (for from and async contexts)
// =============================================================================
@@ -266,11 +442,15 @@ function* normalizeSyncSource(source) {
* and protocol conversions.
* @yields {Uint8Array}
*/
-async function* normalizeAsyncValue(value, allowNestedAsyncStreamables = true) {
+async function* normalizeAsyncValue(
+ value, allowNestedAsyncStreamables = true, context) {
+ throwIfNormalizationCancelled(context);
+
// Handle promises first
if (isPromise(value)) {
- const resolved = await value;
- yield* normalizeAsyncValue(resolved, allowNestedAsyncStreamables);
+ const resolved = await waitForNormalization(value, context);
+ yield* normalizeAsyncValue(
+ resolved, allowNestedAsyncStreamables, context);
return;
}
@@ -293,9 +473,13 @@ async function* normalizeAsyncValue(value, allowNestedAsyncStreamables = true) {
if (hasProtocol(value, toAsyncStreamable)) {
const result = FunctionPrototypeCall(value[toAsyncStreamable], value);
if (isPromise(result)) {
- yield* normalizeAsyncValue(await result, allowNestedAsyncStreamables);
+ yield* normalizeAsyncValue(
+ await waitForNormalization(result, context),
+ allowNestedAsyncStreamables,
+ context);
} else {
- yield* normalizeAsyncValue(result, allowNestedAsyncStreamables);
+ yield* normalizeAsyncValue(
+ result, allowNestedAsyncStreamables, context);
}
return;
}
@@ -303,14 +487,15 @@ async function* normalizeAsyncValue(value, allowNestedAsyncStreamables = true) {
// Handle ToStreamable protocol
if (hasProtocol(value, toStreamable)) {
const result = FunctionPrototypeCall(value[toStreamable], value);
- yield* normalizeAsyncValue(result, allowNestedAsyncStreamables);
+ yield* normalizeAsyncValue(result, allowNestedAsyncStreamables, context);
return;
}
// Handle arrays (which are also iterable, but check first for efficiency)
if (ArrayIsArray(value)) {
for (let i = 0; i < value.length; i++) {
- yield* normalizeAsyncValue(value[i], allowNestedAsyncStreamables);
+ yield* normalizeAsyncValue(
+ value[i], allowNestedAsyncStreamables, context);
}
return;
}
@@ -318,8 +503,9 @@ async function* normalizeAsyncValue(value, allowNestedAsyncStreamables = true) {
// Handle async iterables (check before sync iterables since some objects
// have both)
if (isAsyncIterable(value)) {
- for await (const item of value) {
- yield* normalizeAsyncValue(item, allowNestedAsyncStreamables);
+ const iterable = yieldNormalizationAbortable(value, context);
+ for await (const item of iterable) {
+ yield* normalizeAsyncValue(item, allowNestedAsyncStreamables, context);
}
return;
}
@@ -327,7 +513,7 @@ async function* normalizeAsyncValue(value, allowNestedAsyncStreamables = true) {
// Handle sync iterables
if (isSyncIterable(value)) {
for (const item of value) {
- yield* normalizeAsyncValue(item, allowNestedAsyncStreamables);
+ yield* normalizeAsyncValue(item, allowNestedAsyncStreamables, context);
}
return;
}
@@ -346,10 +532,13 @@ async function* normalizeAsyncValue(value, allowNestedAsyncStreamables = true) {
* @param {AsyncIterable|Iterable} source
* @yields {Uint8Array[]}
*/
-async function* normalizeAsyncSource(source) {
+async function* normalizeAsyncSource(source, context) {
+ throwIfNormalizationCancelled(context);
+
// Prefer async iteration if available
if (isAsyncIterable(source)) {
- for await (const value of source) {
+ const iterable = yieldNormalizationAbortable(source, context);
+ for await (const value of iterable) {
// Fast path 1: value is already a Uint8Array[] batch
if (isUint8ArrayBatch(value)) {
if (value.length > 0) {
@@ -364,7 +553,7 @@ async function* normalizeAsyncSource(source) {
}
// Slow path: normalize the value
let batch = [];
- for await (const chunk of normalizeAsyncValue(value)) {
+ for await (const chunk of normalizeAsyncValue(value, true, context)) {
ArrayPrototypePush(batch, chunk);
if (batch.length === FROM_BATCH_SIZE) {
yield batch;
@@ -383,6 +572,7 @@ async function* normalizeAsyncSource(source) {
let batch = [];
for (const value of source) {
+ throwIfNormalizationCancelled(context);
// Fast path 1: value is already a Uint8Array[] batch
if (isUint8ArrayBatch(value)) {
// Flush any accumulated batch first
@@ -408,7 +598,7 @@ async function* normalizeAsyncSource(source) {
batch = [];
}
let asyncBatch = [];
- for await (const chunk of normalizeAsyncValue(value, false)) {
+ for await (const chunk of normalizeAsyncValue(value, false, context)) {
ArrayPrototypePush(asyncBatch, chunk);
if (asyncBatch.length === FROM_BATCH_SIZE) {
yield asyncBatch;
@@ -434,6 +624,12 @@ async function* normalizeAsyncSource(source) {
);
}
+async function* normalizeAsyncStreamableResult(result, context) {
+ const resolved = await waitForNormalization(result, context);
+ const source = resolved?.[kValidatedSource] ? resolved : from(resolved);
+ yield* yieldNormalizationAbortable(source, context);
+}
+
// =============================================================================
// Public API: from() and fromSync()
// =============================================================================
@@ -610,19 +806,8 @@ function from(input) {
if (result?.[kValidatedSource]) {
return result;
}
- return {
- __proto__: null,
- async *[SymbolAsyncIterator]() {
- // The result may be a Promise. Check validated on both the Promise
- // itself (if tagged) and the resolved value.
- const resolved = await result;
- if (resolved?.[kValidatedSource]) {
- yield* resolved[SymbolAsyncIterator]();
- return;
- }
- yield* from(resolved)[SymbolAsyncIterator]();
- },
- };
+ return createNormalizationSource(
+ (context) => normalizeAsyncStreamableResult(result, context));
}
// Check toStreamable protocol (takes precedence over iteration protocols)
@@ -640,7 +825,8 @@ function from(input) {
);
}
- return normalizeAsyncSource(input);
+ return createNormalizationIterator(
+ (context) => normalizeAsyncSource(input, context));
}
// =============================================================================
diff --git a/test/parallel/test-stream-iter-from-async.js b/test/parallel/test-stream-iter-from-async.js
index a4726d633ef..29ecdf42576 100644
--- a/test/parallel/test-stream-iter-from-async.js
+++ b/test/parallel/test-stream-iter-from-async.js
@@ -3,7 +3,8 @@
const common = require('../common');
const assert = require('assert');
-const { from, text, Stream } = require('stream/iter');
+const { bytes, from, text, Stream } = require('stream/iter');
+const { setImmediate } = require('timers/promises');
async function testFromString() {
const readable = from('hello-async');
@@ -31,6 +32,55 @@ async function testFromAsyncGenerator() {
assert.deepStrictEqual(batches[1][0], new Uint8Array([30, 40]));
}
+async function testFromAsyncIteratorResultShapes() {
+ const wrappers = [
+ (result) => result,
+ (result) => ({
+ then(resolve) {
+ resolve(result);
+ },
+ }),
+ ];
+
+ for (const wrap of wrappers) {
+ let done = false;
+ const source = {
+ [Symbol.asyncIterator]() {
+ return {
+ next() {
+ if (done) return wrap({ done: true });
+ done = true;
+ return wrap({ done: false, value: 'data' });
+ },
+ };
+ },
+ };
+
+ assert.strictEqual(await text(from(source)), 'data');
+ }
+}
+
+async function testFromSourceErrorDoesNotWaitForReturn() {
+ const reason = new Error('source failed');
+ const source = {
+ [Symbol.asyncIterator]() {
+ return {
+ next() {
+ return Promise.reject(reason);
+ },
+ return() {
+ return new Promise(() => {});
+ },
+ };
+ },
+ };
+
+ await assert.rejects(
+ from(source).next(),
+ (error) => error === reason,
+ );
+}
+
async function testFromBoundsNestedAsyncIterable() {
let nestedClosed = false;
async function* nested() {
@@ -266,13 +316,126 @@ async function testFromHandlesProtocolRejectionUntilIteration() {
() => Promise.reject(reason)),
});
- await new Promise(setImmediate);
+ await setImmediate();
await assert.rejects(
iterable[Symbol.asyncIterator]().next(),
(error) => error === reason,
);
}
+async function testFromReturnCancelsPendingPromises() {
+ const toAsyncStreamable = Symbol.for('Stream.toAsyncStreamable');
+ const createSources = [
+ (promise) => from([promise]),
+ (promise) => from({
+ [Symbol.asyncIterator]() {
+ let done = false;
+ return {
+ next() {
+ if (done) return { done: true };
+ done = true;
+ return { done: false, value: promise };
+ },
+ };
+ },
+ }),
+ (promise) => from({
+ [toAsyncStreamable]() {
+ return promise;
+ },
+ }),
+ ];
+
+ for (const createSource of createSources) {
+ const deferred = Promise.withResolvers();
+ const iterator = createSource(deferred.promise)[Symbol.asyncIterator]();
+ const read = iterator.next();
+ await setImmediate();
+
+ const rejected = assert.rejects(read, { name: 'AbortError' });
+ const closed = iterator.return();
+ const [, result] = await Promise.all([rejected, closed]);
+ assert.strictEqual(result.done, true);
+ deferred.resolve('late value');
+ }
+}
+
+function createPendingNestedSource(
+ returnResult = () => ({ done: true })) {
+ const started = Promise.withResolvers();
+ const pending = Promise.withResolvers();
+ let returned = false;
+ const nested = {
+ [Symbol.asyncIterator]() {
+ return {
+ next() {
+ started.resolve();
+ return pending.promise;
+ },
+ return() {
+ returned = true;
+ return returnResult();
+ },
+ };
+ },
+ };
+
+ async function* source() {
+ yield nested;
+ }
+
+ return {
+ source: source(),
+ started: started.promise,
+ resolve: pending.resolve,
+ wasReturned() {
+ return returned;
+ },
+ };
+}
+
+async function testFromReturnClosesPendingNestedIterator() {
+ const fixture = createPendingNestedSource();
+ const iterator = from(fixture.source)[Symbol.asyncIterator]();
+ const read = iterator.next();
+ await fixture.started;
+
+ const rejected = assert.rejects(read, { name: 'AbortError' });
+ const closed = iterator.return();
+ await Promise.all([rejected, closed]);
+ assert.strictEqual(fixture.wasReturned(), true);
+ fixture.resolve({ done: true });
+}
+
+async function testConsumerAbortClosesPendingNestedIterator() {
+ const fixture = createPendingNestedSource();
+ const controller = new AbortController();
+ const reason = new Error('consumer cancelled');
+ const consumed = bytes(fixture.source, { signal: controller.signal });
+ await fixture.started;
+
+ const rejected = assert.rejects(consumed, (error) => error === reason);
+ controller.abort(reason);
+ await rejected;
+ await setImmediate();
+ assert.strictEqual(fixture.wasReturned(), true);
+ fixture.resolve({ done: true });
+}
+
+async function testFromCancellationHandlesCleanupRejection() {
+ const fixture = createPendingNestedSource(
+ () => Promise.reject(new Error('cleanup failed')));
+ const iterator = from(fixture.source)[Symbol.asyncIterator]();
+ const read = iterator.next();
+ await fixture.started;
+
+ const rejected = assert.rejects(read, { name: 'AbortError' });
+ await Promise.all([rejected, iterator.return()]);
+ await setImmediate();
+ assert.strictEqual(fixture.wasReturned(), true);
+ fixture.resolve({ done: true });
+}
+
// DataView input should be converted to Uint8Array (zero-copy)
async function testFromDataView() {
const buf = new ArrayBuffer(5);
@@ -298,6 +461,8 @@ function testFromUndefinedThrows() {
Promise.all([
testFromString(),
testFromAsyncGenerator(),
+ testFromAsyncIteratorResultShapes(),
+ testFromSourceErrorDoesNotWaitForReturn(),
testFromBoundsNestedAsyncIterable(),
testFromSyncIterableAsAsync(),
testFromSyncIterableAwaitsPromiseValues(),
@@ -319,5 +484,9 @@ Promise.all([
testFromTopLevelAsyncPrecedence(),
testFromTopLevelProtocolOverIterator(),
testFromHandlesProtocolRejectionUntilIteration(),
+ testFromReturnCancelsPendingPromises(),
+ testFromReturnClosesPendingNestedIterator(),
+ testConsumerAbortClosesPendingNestedIterator(),
+ testFromCancellationHandlesCleanupRejection(),
testFromDataView(),
]).then(common.mustCall());