Skip to content

Commit 79f7b0c

Browse files
committed
stream: read stream/iter from() sources in pipelines without a promise per read
A pull() pipeline, pipeTo() with a signal and the consumers with a signal reject their own pending pull, pipe or read when they are aborted, and ignore the read of the source that is still pending then. Yet they read a from() async source with its cancellable next(), which gives every read a promise of its own so that a cancellation can reject it at once: about 230 bytes per batch, for a rejection nobody waits for. They now read such sources with kNextUncancellable, as pipeTo() without a signal already did, when nothing but stream/iter code waits for the reads: always for pipeTo() and the consumers, and for pull() pipelines without stateful transforms. A stateful transform's generator waits for the source in user code, so with one the reads stay cancellable and its finally block still runs at once. What makes this possible is that a cancellation now releases a pending kNextUncancellable read instead of waiting for it: the normalization ends, the source is closed at once (as for a cancelled next(), errors ignored), and the operations queued behind the read, such as the return() that cancelled it, run. The read settles when the source's next() does, rejecting with the cancellation reason, and is ignored. A pipeline stopped with return() or throw() while a pull is pending closes its (stateless) transform layers without waiting for them, since they wait for that read. Allocation per batch, 16-byte chunks, async source: - pipeTo() with a signal: 967 -> 738 bytes (as without a signal) - pull() with one transform: 1518 -> 1289 bytes - pipeTo() with a signal and one transform: 1829 -> 1601 bytes test-stream-iter-abort-pending-read.js checks, for pull(), pipeTo() and bytes(), with and without transforms and stopped by a signal, return() or throw() while the source's next() is pending, that the call rejects at once, the source is closed before that next() settles, and nothing happens when it does. It also passes before this change. Assisted-by: OpenCode
1 parent 0fb5beb commit 79f7b0c

4 files changed

Lines changed: 287 additions & 23 deletions

File tree

‎lib/internal/streams/iter/consumers.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const {
5454
fromSync,
5555
isAsyncIterable,
5656
isSyncIterable,
57+
kNextUncancellable,
5758
} = require('internal/streams/iter/from');
5859

5960
const {
@@ -170,10 +171,15 @@ async function collectAsync(source, signal, limit) {
170171

171172
// Read as for await...of does, stopping once the signal aborts; see
172173
// raceSignal().
174+
// A from() source is read with kNextUncancellable: on an abort,
175+
// raceSignal() rejects and closes the source at once, and a read still
176+
// pending is ignored.
173177
const state = { __proto__: null, aborted: false };
174178
async function consume(iterator) {
179+
const uncancellable = iterator[kNextUncancellable] !== undefined;
175180
for (;;) {
176-
const result = await iterator.next();
181+
const result = await (uncancellable ?
182+
iterator[kNextUncancellable]() : iterator.next());
177183
if (result.done || state.aborted) return;
178184
try {
179185
recordBatch(result.value);

‎lib/internal/streams/iter/from.js‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,13 @@ function waitForNormalization(value, context) {
152152
const kNextSyncBatch = Symbol('kNextSyncBatch');
153153

154154
// The method of an iterator returned by from() for an async iterable that
155-
// reads like next() when nothing can cancel the normalization while a read
156-
// is pending, for pipeTo(): see createAsyncSourceNormalizer().
155+
// reads like next(), for internal callers that stop reading when they
156+
// cancel the normalization (pipeTo(), pull() pipelines, consumers): a
157+
// return() or throw() while a read is pending closes the source at once,
158+
// as for next(), but does not settle the pending read early. That read
159+
// settles when the source's next() does, rejecting with the cancellation
160+
// reason, and the caller ignores it. It saves the read a promise of its
161+
// own: see createAsyncSourceNormalizer().
157162
const kNextUncancellable = Symbol('kNextUncancellable');
158163

159164
// Methods of the iterator returned by yieldNormalizationAbortable(), used by
@@ -591,11 +596,12 @@ function yieldNormalizationAbortable(source, context) {
591596
});
592597
return promise;
593598
},
594-
// Like next(), when nothing cancels the normalization while the read
595-
// is pending, in two parts so that the caller handles the result in
596-
// the same reaction: kReadUncancellable starts the read, returning a
597-
// promise for the source's result, and the caller passes that result
598-
// to kToIterResult, or calls kReadFailed if the promise rejects.
599+
// Like next(), for a caller that ignores the read if it cancels the
600+
// normalization while the read is pending (see kNextUncancellable),
601+
// in two parts so that the caller handles the result in the same
602+
// reaction: kReadUncancellable starts the read, returning a promise
603+
// for the source's result, and the caller passes that result to
604+
// kToIterResult, or calls kReadFailed if the promise rejects.
599605
[kReadUncancellable]() {
600606
if (completed) {
601607
return PromiseResolve(kDoneSourceResult);
@@ -836,9 +842,11 @@ function createAsyncSourceNormalizer(source, context) {
836842
let boundedBatches = null;
837843
let valueBatches = null;
838844
// Whether the source is read with kNextUncancellable: set by the
839-
// kNextUncancellable method, for callers that never cancel the
840-
// normalization while a read is pending.
845+
// kNextUncancellable method, for callers that ignore a read pending when
846+
// they cancel the normalization. readReleased: whether a cancellation has
847+
// released such a read (see onUncancellableCancelled()).
841848
let uncancellable = false;
849+
let readReleased = false;
842850
// Whether the source is read with kRead, and the settling functions of
843851
// the read's promise while it is pending.
844852
let readable = false;
@@ -915,6 +923,8 @@ function createAsyncSourceNormalizer(source, context) {
915923
// A kReadUncancellable result: handled as the result of the source's
916924
// next() is, in the same reaction.
917925
function onUncancellableResult(result) {
926+
if (readReleased) throw context.reason;
927+
context.resolve = null;
918928
let iterResult;
919929
try {
920930
iterResult = iterator[kToIterResult](result);
@@ -925,10 +935,41 @@ function createAsyncSourceNormalizer(source, context) {
925935
}
926936

927937
function onUncancellableError(error) {
938+
if (readReleased) throw context.reason;
939+
context.resolve = null;
928940
iterator[kReadFailed]();
929941
return fail(error);
930942
}
931943

944+
// context.resolve while a kNextUncancellable read is pending: the caller
945+
// ignores that read, so it is released rather than waited for. The
946+
// normalization ends, the source is closed at once, as for a cancelled
947+
// kRead (errors ignored), and the operations queued behind the read,
948+
// such as the return() that cancelled it, run now. The read settles when
949+
// the source's next() does, rejecting with the cancellation reason.
950+
function onUncancellableCancelled() {
951+
context.resolve = null;
952+
readReleased = true;
953+
state = kDone;
954+
markPromiseAsHandled(iterator.return());
955+
settled();
956+
}
957+
958+
function readUncancellable() {
959+
const cancelledBefore = context.cancelled;
960+
const read = iterator[kReadUncancellable]();
961+
if (!cancelledBefore) {
962+
// Cancelled while the source's next() was called, or later.
963+
if (context.cancelled) {
964+
onUncancellableCancelled();
965+
} else {
966+
context.resolve = onUncancellableCancelled;
967+
}
968+
}
969+
return PromisePrototypeThen(read, onUncancellableResult,
970+
onUncancellableError);
971+
}
972+
932973
// The kRead handlers: the result is handled in the reaction to the
933974
// source's result, and settles the read's promise.
934975
function onRead(result) {
@@ -954,10 +995,7 @@ function createAsyncSourceNormalizer(source, context) {
954995
}
955996

956997
function pullSource() {
957-
if (uncancellable) {
958-
return PromisePrototypeThen(iterator[kReadUncancellable](),
959-
onUncancellableResult, onUncancellableError);
960-
}
998+
if (uncancellable) return readUncancellable();
961999
if (readable) {
9621000
// One promise for the read, which a cancellation rejects, instead of
9631001
// next()'s promise and a reaction to it.

‎lib/internal/streams/iter/pull.js‎

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,20 +1233,44 @@ class PipelineSource {
12331233
#abort;
12341234
#iterator = null;
12351235
#closed = false;
1236-
1237-
constructor(source, abort) {
1236+
// Whether a from() source is read with kNextUncancellable (see the
1237+
// constructor).
1238+
#uncancellable;
1239+
1240+
// `uncancellable`: whether nothing but stream/iter code waits for the
1241+
// reads, so that a read pending when the pipeline aborts can be left to
1242+
// settle when the source's next() does. The pipeline rejects its own
1243+
// pending pull and ignores the read, and closing the source still calls
1244+
// its return() at once. This saves every read of a from() source a
1245+
// promise of its own. A stateful transform reads the source in user code
1246+
// (its generator waits for the read), so with one the reads stay
1247+
// cancellable, and an abort rejects them at once.
1248+
constructor(source, abort, uncancellable) {
12381249
this.#source = source;
12391250
this.#abort = abort;
1251+
this.#uncancellable = uncancellable;
12401252
}
12411253

12421254
[SymbolAsyncIterator]() {
12431255
return this;
12441256
}
12451257

1258+
// Whether the source is read with kNextUncancellable (known once it has
1259+
// been read).
1260+
get uncancellable() {
1261+
return this.#uncancellable && this.#iterator !== null;
1262+
}
1263+
12461264
next() {
12471265
if (this.#abort.aborted) return PromiseReject(this.#abort.reason);
12481266
if (this.#closed) return PromiseResolve(new IterResult(true, undefined));
1249-
this.#iterator ??= this.#source[SymbolAsyncIterator]();
1267+
if (this.#iterator === null) {
1268+
this.#iterator = this.#source[SymbolAsyncIterator]();
1269+
if (this.#iterator[kNextUncancellable] === undefined) {
1270+
this.#uncancellable = false;
1271+
}
1272+
}
1273+
if (this.#uncancellable) return this.#iterator[kNextUncancellable]();
12501274
return this.#iterator.next();
12511275
}
12521276

@@ -1290,6 +1314,13 @@ class PipelineSource {
12901314
}
12911315
}
12921316

1317+
function hasTransformObject(transforms) {
1318+
for (let i = 0; i < transforms.length; i++) {
1319+
if (isTransformObject(transforms[i])) return true;
1320+
}
1321+
return false;
1322+
}
1323+
12931324
/**
12941325
* Build the chain of transform layers of a pipeline.
12951326
* @param {AsyncIterable<Uint8Array[]>} normalized
@@ -1377,6 +1408,8 @@ function createAsyncPipeline(source, transforms, signal,
13771408
// runs one pull at a time.
13781409
let resolvePull = null;
13791410
let rejectPull = null;
1411+
// See stop().
1412+
let closeWithoutWaiting = false;
13801413
const operations = createOperationQueue();
13811414

13821415
const self = ObjectSetPrototypeOf({
@@ -1437,6 +1470,14 @@ function createAsyncPipeline(source, transforms, signal,
14371470
if (rejectPull !== null) {
14381471
abortPipeline(reason, false);
14391472
state = kActive;
1473+
// The pending pull is waiting for a read of the source that is now
1474+
// ignored, and with uncancellable reads nothing settles that read
1475+
// early: closing the transforms would wait for it, as a transform
1476+
// layer's return() waits for its pending next(). The transforms are
1477+
// all stateless then (see PipelineSource), so nothing but stream/iter
1478+
// code waits for them, and doReturn() / doThrow() close them without
1479+
// waiting. The source has already been closed.
1480+
closeWithoutWaiting = pipelineSource.uncancellable;
14401481
} else {
14411482
abort.abort(reason);
14421483
}
@@ -1542,7 +1583,8 @@ function createAsyncPipeline(source, transforms, signal,
15421583
if (state === kStart) {
15431584
state = kActive;
15441585
abort = new PipelineAbort();
1545-
pipelineSource = new PipelineSource(source, abort);
1586+
pipelineSource = new PipelineSource(
1587+
source, abort, !hasTransformObject(transforms));
15461588
if (signal !== undefined) {
15471589
signal.addEventListener('abort', onSignalAbort,
15481590
{ __proto__: null, once: true,
@@ -1600,6 +1642,11 @@ function createAsyncPipeline(source, transforms, signal,
16001642
}
16011643
state = kDone;
16021644
removeSignalListener();
1645+
if (closeWithoutWaiting) {
1646+
markPromiseAsHandled(closeAsyncIterator(iterator, false));
1647+
operations.settled();
1648+
return PromiseResolve(result);
1649+
}
16031650
return PromisePrototypeThen(
16041651
closeAsyncIterator(iterator, false), () => {
16051652
operations.settled();
@@ -1618,6 +1665,11 @@ function createAsyncPipeline(source, transforms, signal,
16181665
}
16191666
state = kDone;
16201667
removeSignalListener();
1668+
if (closeWithoutWaiting) {
1669+
markPromiseAsHandled(closeAsyncIterator(iterator, true));
1670+
operations.settled();
1671+
return PromiseReject(error);
1672+
}
16211673
return PromisePrototypeThen(
16221674
closeAsyncIterator(iterator, true), () => {
16231675
operations.settled();
@@ -2040,10 +2092,14 @@ async function pipeTo(source, ...args) {
20402092
}
20412093

20422094
// Write the batches of a source read with next(), as the for await...of
2043-
// loop below does, stopping once `signal` aborts.
2095+
// loop below does, stopping once `signal` aborts. A from() source is read
2096+
// with kNextUncancellable: on an abort, raceSignal() rejects and closes
2097+
// the source at once, and a read still pending is ignored.
20442098
async function pipeSourceWithSignal(iterator) {
2099+
const uncancellable = iterator[kNextUncancellable] !== undefined;
20452100
for (;;) {
2046-
const result = await iterator.next();
2101+
const result = await (uncancellable ?
2102+
iterator[kNextUncancellable]() : iterator.next());
20472103
if (result.done || abortState.aborted) return;
20482104
try {
20492105
const p = writeBatch(result.value);
@@ -2144,9 +2200,8 @@ async function pipeTo(source, ...args) {
21442200
if (transforms.length === 0) {
21452201
// Fast path: no transforms - iterate normalized source directly
21462202
if (signal) {
2147-
// Batches are read synchronously when possible, as without a signal.
2148-
// Async reads go through next(), which return() can cancel while it
2149-
// is pending, unlike kNextUncancellable.
2203+
// Batches are read synchronously when possible, as without a signal,
2204+
// otherwise as pipeSourceWithSignal() says.
21502205
const iterator = normalized[SymbolAsyncIterator]();
21512206
await raceSignal(iterator, signal, abortState,
21522207
iterator[kNextSyncBatch] !== undefined ?

0 commit comments

Comments
 (0)