Skip to content

Commit 906ea04

Browse files
committed
stream: trim per-stream costs in webstreams
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>
1 parent 5c5bd22 commit 906ea04

4 files changed

Lines changed: 156 additions & 150 deletions

File tree

lib/internal/webstreams/readablestream.js

Lines changed: 69 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,13 @@ class ReadableStream {
253253
*/
254254
constructor(source = kEmptyObject, strategy = kEmptyObject) {
255255
markTransferMode(this, false, true);
256+
// Internal construction (tee, transform streams, adapters, transfer):
257+
// the caller sets up the controller, so every ReadableStream shares
258+
// one hidden class and no per-instance prototype swap is needed.
259+
if (source === kSkipThrow) {
260+
this[kState] = createReadableStreamState();
261+
return;
262+
}
256263
validateObject(source, 'source', kValidateObjectAllowObjects);
257264
validateObject(strategy, 'strategy', kValidateObjectAllowObjectsAndNull);
258265
this[kState] = createReadableStreamState();
@@ -718,22 +725,8 @@ ObjectDefineProperties(ReadableStream, {
718725
from: kEnumerableProperty,
719726
});
720727

721-
function InternalTransferredReadableStream() {
722-
ObjectSetPrototypeOf(this, ReadableStream.prototype);
723-
markTransferMode(this, false, true);
724-
this[kType] = 'ReadableStream';
725-
this[kState] = createReadableStreamState();
726-
}
727-
728-
ObjectSetPrototypeOf(InternalTransferredReadableStream.prototype, ReadableStream.prototype);
729-
ObjectSetPrototypeOf(InternalTransferredReadableStream, ReadableStream);
730-
731728
function TransferredReadableStream() {
732-
const stream = new InternalTransferredReadableStream();
733-
734-
stream.constructor = ReadableStream;
735-
736-
return stream;
729+
return new ReadableStream(kSkipThrow);
737730
}
738731

739732
TransferredReadableStream.prototype[kDeserialize] = () => {};
@@ -1350,57 +1343,29 @@ ObjectDefineProperties(ReadableByteStreamController.prototype, {
13501343
[SymbolToStringTag]: getNonWritablePropertyDescriptor(ReadableByteStreamController.name),
13511344
});
13521345

1353-
function InternalReadableStream(start, pull, cancel, highWaterMark, size) {
1354-
ObjectSetPrototypeOf(this, ReadableStream.prototype);
1355-
markTransferMode(this, false, true);
1356-
this[kType] = 'ReadableStream';
1357-
this[kState] = createReadableStreamState();
1358-
const controller = new ReadableStreamDefaultController(kSkipThrow);
1346+
function createReadableStream(start, pull, cancel, highWaterMark = 1, size = defaultSizeAlgorithm) {
1347+
const stream = new ReadableStream(kSkipThrow);
13591348
setupReadableStreamDefaultController(
1360-
this,
1361-
controller,
1349+
stream,
1350+
new ReadableStreamDefaultController(kSkipThrow),
13621351
start,
13631352
pull,
13641353
cancel,
13651354
highWaterMark,
13661355
size);
1367-
}
1368-
1369-
ObjectSetPrototypeOf(InternalReadableStream.prototype, ReadableStream.prototype);
1370-
ObjectSetPrototypeOf(InternalReadableStream, ReadableStream);
1371-
1372-
function createReadableStream(start, pull, cancel, highWaterMark = 1, size = defaultSizeAlgorithm) {
1373-
const stream = new InternalReadableStream(start, pull, cancel, highWaterMark, size);
1374-
1375-
// For spec compliance the InternalReadableStream must be a ReadableStream
1376-
stream.constructor = ReadableStream;
13771356
return stream;
13781357
}
13791358

1380-
function InternalReadableByteStream(start, pull, cancel) {
1381-
ObjectSetPrototypeOf(this, ReadableStream.prototype);
1382-
markTransferMode(this, false, true);
1383-
this[kType] = 'ReadableStream';
1384-
this[kState] = createReadableStreamState();
1385-
const controller = new ReadableByteStreamController(kSkipThrow);
1359+
function createReadableByteStream(start, pull, cancel) {
1360+
const stream = new ReadableStream(kSkipThrow);
13861361
setupReadableByteStreamController(
1387-
this,
1388-
controller,
1362+
stream,
1363+
new ReadableByteStreamController(kSkipThrow),
13891364
start,
13901365
pull,
13911366
cancel,
13921367
0,
13931368
undefined);
1394-
}
1395-
1396-
ObjectSetPrototypeOf(InternalReadableByteStream.prototype, ReadableStream.prototype);
1397-
ObjectSetPrototypeOf(InternalReadableByteStream, ReadableStream);
1398-
1399-
function createReadableByteStream(start, pull, cancel) {
1400-
const stream = new InternalReadableByteStream(start, pull, cancel);
1401-
1402-
// For spec compliance the InternalReadableByteStream must be a ReadableStream
1403-
stream.constructor = ReadableStream;
14041369
return stream;
14051370
}
14061371

@@ -1630,6 +1595,13 @@ function readableStreamPipeTo(
16301595
// tells us that the promise must be rejected even
16311596
// when error is undefine.
16321597
function finalize(rejected, error) {
1598+
// The pipe is the only observer of the reader's and writer's promise
1599+
// records (including the ready hook installed by parkOnReady), and
1600+
// it is done with them: dropping them lets release skip the
1601+
// pending-promise probes and the rejections nothing would handle.
1602+
writer[kState].ready = undefined;
1603+
writer[kState].close = undefined;
1604+
reader[kState].close = undefined;
16331605
writableStreamDefaultWriterRelease(writer);
16341606
readableStreamReaderGenericRelease(reader);
16351607
if (signal !== undefined)
@@ -1727,12 +1699,6 @@ function readableStreamPipeTo(
17271699
PromisePrototypeThen(promise, undefined, action);
17281700
}
17291701

1730-
function watchClosed(stream, promise, action) {
1731-
if (stream[kState].state === 'closed')
1732-
action();
1733-
else
1734-
PromisePrototypeThen(promise, action, () => {});
1735-
}
17361702

17371703
// The pump loop is callback-driven to avoid per-iteration promise
17381704
// allocations. At most one read is in flight at a time, so one read
@@ -1863,15 +1829,34 @@ function readableStreamPipeTo(
18631829

18641830
pump();
18651831

1866-
watchErrored(source, readerClosedPromise(reader).promise, (error) => {
1832+
function onSourceErrored(error) {
18671833
if (!preventAbort) {
18681834
return shutdownWithAnAction(
18691835
() => writableStreamAbort(dest, error),
18701836
true,
18711837
error);
18721838
}
18731839
shutdown(true, error);
1874-
});
1840+
}
1841+
1842+
function onSourceClosed() {
1843+
if (!preventClose) {
1844+
return shutdownWithAnAction(
1845+
() => writableStreamDefaultWriterCloseWithErrorPropagation(writer));
1846+
}
1847+
shutdown();
1848+
}
1849+
1850+
// The spec installs the source-errored watcher before the dest-errored
1851+
// one and the source-closed watcher last; a source that is already
1852+
// errored is handled before the dest watcher is installed, and an
1853+
// already-closed source after it, as before.
1854+
if (source[kState].state === 'errored') {
1855+
onSourceErrored(source[kState].storedError);
1856+
} else if (source[kState].state !== 'closed') {
1857+
PromisePrototypeThen(
1858+
readerClosedPromise(reader).promise, onSourceClosed, onSourceErrored);
1859+
}
18751860

18761861
watchErrored(dest, writerClosedPromise(writer).promise, (error) => {
18771862
if (!preventCancel) {
@@ -1883,13 +1868,8 @@ function readableStreamPipeTo(
18831868
shutdown(true, error);
18841869
});
18851870

1886-
watchClosed(source, readerClosedPromise(reader).promise, () => {
1887-
if (!preventClose) {
1888-
return shutdownWithAnAction(
1889-
() => writableStreamDefaultWriterCloseWithErrorPropagation(writer));
1890-
}
1891-
shutdown();
1892-
});
1871+
if (source[kState].state === 'closed')
1872+
onSourceClosed();
18931873

18941874
if (writableStreamCloseQueuedOrInFlight(dest) ||
18951875
dest[kState].state === 'closed') {
@@ -2899,29 +2879,27 @@ function setupReadableStreamDefaultController(
28992879

29002880
const startResult = startAlgorithm();
29012881

2882+
const started = () => {
2883+
controller[kState].started = true;
2884+
assert(!controller[kState].pulling);
2885+
assert(!controller[kState].pullAgain);
2886+
readableStreamDefaultControllerCallPullIfNeeded(controller);
2887+
};
2888+
29022889
if (startResult === null ||
29032890
(typeof startResult !== 'object' && typeof startResult !== 'function')) {
29042891
// Non-thenable start result: fulfillment is guaranteed and no .then
2905-
// lookup on the result is observable, so run the post-start step
2906-
// directly at the exact microtask position the promise reaction
2907-
// would have had, skipping two promise allocations.
2908-
queueMicrotask(() => {
2909-
controller[kState].started = true;
2910-
assert(!controller[kState].pulling);
2911-
assert(!controller[kState].pullAgain);
2912-
readableStreamDefaultControllerCallPullIfNeeded(controller);
2913-
});
2892+
// lookup on the result is observable, so the post-start step runs at
2893+
// the exact microtask position the promise reaction would have had.
2894+
queueMicrotask(started);
29142895
return;
29152896
}
29162897

2898+
// The wrapper promise matches the reference implementation's
2899+
// promiseResolvedWith(), whose extra microtask hops WPT relies on.
29172900
PromisePrototypeThen(
29182901
new Promise((r) => r(startResult)),
2919-
() => {
2920-
controller[kState].started = true;
2921-
assert(!controller[kState].pulling);
2922-
assert(!controller[kState].pullAgain);
2923-
readableStreamDefaultControllerCallPullIfNeeded(controller);
2924-
},
2902+
started,
29252903
(error) => readableStreamDefaultControllerError(controller, error));
29262904
}
29272905

@@ -3783,26 +3761,23 @@ function setupReadableByteStreamController(
37833761

37843762
const startResult = startAlgorithm();
37853763

3764+
const started = () => {
3765+
controller[kState].started = true;
3766+
assert(!controller[kState].pulling);
3767+
assert(!controller[kState].pullAgain);
3768+
readableByteStreamControllerCallPullIfNeeded(controller);
3769+
};
3770+
3771+
// See setupReadableStreamDefaultController.
37863772
if (startResult === null ||
37873773
(typeof startResult !== 'object' && typeof startResult !== 'function')) {
3788-
// See setupReadableStreamDefaultController.
3789-
queueMicrotask(() => {
3790-
controller[kState].started = true;
3791-
assert(!controller[kState].pulling);
3792-
assert(!controller[kState].pullAgain);
3793-
readableByteStreamControllerCallPullIfNeeded(controller);
3794-
});
3774+
queueMicrotask(started);
37953775
return;
37963776
}
37973777

37983778
PromisePrototypeThen(
37993779
new Promise((r) => r(startResult)),
3800-
() => {
3801-
controller[kState].started = true;
3802-
assert(!controller[kState].pulling);
3803-
assert(!controller[kState].pullAgain);
3804-
readableByteStreamControllerCallPullIfNeeded(controller);
3805-
},
3780+
started,
38063781
(error) => readableByteStreamControllerError(controller, error));
38073782
}
38083783

lib/internal/webstreams/util.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,12 @@ class Queue {
179179
// Single-slot entries (readable byte controller chunk records).
180180

181181
push(entry) {
182+
if (this.length === this.list.length)
183+
this.grow();
182184
const tail = this.tail;
183185
this.list[tail] = entry;
184186
this.tail = (tail + 1) & this.capacityMask;
185187
this.length++;
186-
if (this.tail === this.head)
187-
this.grow();
188188
}
189189

190190
shift() {
@@ -207,14 +207,14 @@ class Queue {
207207
// never need to wrap.
208208

209209
pushPair(value, size) {
210+
if (this.length * 2 === this.list.length)
211+
this.grow();
210212
const tail = this.tail;
211213
const list = this.list;
212214
list[tail] = value;
213215
list[tail + 1] = size;
214216
this.tail = (tail + 2) & this.capacityMask;
215217
this.length++;
216-
if (this.tail === this.head)
217-
this.grow();
218218
}
219219

220220
// Returns the dequeued value; the size of the same entry is left in
@@ -237,9 +237,11 @@ class Queue {
237237
return this.list[this.head];
238238
}
239239

240-
// The ring is completely full (the post-push tail caught up with the
241-
// head): double the capacity, re-linearizing from the head so index
242-
// arithmetic stays trivial.
240+
// The ring is completely full (the tail has caught up with the head, so
241+
// the next push would overwrite the oldest entry): double the capacity,
242+
// re-linearizing from the head so index arithmetic stays trivial.
243+
// Growing before the push rather than after it lets the initial 8-slot
244+
// ring hold four (value, size) pairs without reallocating.
243245
grow() {
244246
const list = this.list;
245247
const capacity = list.length;

0 commit comments

Comments
 (0)