GeezerVison is showing github.com — stream: cut promise churn in webstreams hot paths · nodejs/node@c577669
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: lib/internal/webstreams/readablestream.js +62-17Lines changed: 62 additions & 17 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -101,6 +101,7 @@ const { | 101 | 101 | cloneAsUint8Array, | 102 | 102 | copyArrayBuffer, | 103 | 103 | createPromiseCallback1Param, | | 104 | + createRawCallback1Param, | 104 | 105 | customInspect, | 105 | 106 | defaultSizeAlgorithm, | 106 | 107 | dequeueValue, | @@ -110,6 +111,7 @@ const { | 110 | 111 | getNonWritablePropertyDescriptor, | 111 | 112 | isBrandCheck, | 112 | 113 | kEmptyQueue, | | 114 | + kResolvedPromise, | 113 | 115 | kState, | 114 | 116 | kType, | 115 | 117 | lazyTransfer, | @@ -121,6 +123,7 @@ const { | 121 | 123 | resetQueue, | 122 | 124 | resolvedRecord, | 123 | 125 | setPromiseHandled, | | 126 | + thenAlgorithmResult, | 124 | 127 | } = require('internal/webstreams/util'); | 125 | 128 |
| 126 | 129 | const { | @@ -137,7 +140,6 @@ const { | 137 | 140 | writableStreamDefaultWriterRelease, | 138 | 141 | writableStreamDefaultWriterWriteWithRequest, | 139 | 142 | writerClosedPromise, | 140 | | - writerReadyPromise, | 141 | 143 | } = require('internal/webstreams/writablestream'); | 142 | 144 |
| 143 | 145 | const { Buffer } = require('buffer'); | @@ -1664,11 +1666,31 @@ function readableStreamPipeTo( | 1664 | 1666 | // the chunk travels through `pendingChunk`. | 1665 | 1667 | let pendingChunk; | 1666 | 1668 | let readRequest; | | 1669 | + let readyHook; | 1667 | 1670 |
| 1668 | 1671 | // Ready promise rejection is handled by the destination-errored | 1669 | 1672 | // watcher. | 1670 | 1673 | function ignoreReadyRejection() {} | 1671 | 1674 |
| | 1675 | + // Parks the pump on the destination's backpressure by installing a | | 1676 | + // record that duck-types the writer's lazily-materialized | | 1677 | + // [[readyPromise]] record: writableStreamUpdateBackpressure resolves it | | 1678 | + // when backpressure clears (after publishing the new backpressure | | 1679 | + // state), which re-enters the pump directly instead of rotating a | | 1680 | + // fresh promise record plus reaction per flip. The pipe holds the only | | 1681 | + // reference to the writer, so the record is never observable as a real | | 1682 | + // ready promise; the erroring/release paths probe `promise` via | | 1683 | + // isPromisePending() and call `reject`, so it carries a real | | 1684 | + // forever-pending promise and a no-op reject. | | 1685 | + function parkOnReady() { | | 1686 | + readyHook ??= { | | 1687 | + promise: new Promise(nonOpCallback), | | 1688 | + resolve: pump, | | 1689 | + reject: ignoreReadyRejection, | | 1690 | + }; | | 1691 | + writer[kState].ready = readyHook; | | 1692 | + } | | 1693 | + | 1672 | 1694 | function forwardChunk() { | 1673 | 1695 | const chunk = pendingChunk; | 1674 | 1696 | pendingChunk = undefined; | @@ -1680,10 +1702,7 @@ function readableStreamPipeTo( | 1680 | 1702 | if (shuttingDown) return; | 1681 | 1703 |
| 1682 | 1704 | if (dest[kState].backpressure) { | 1683 | | - PromisePrototypeThen( | 1684 | | - writerReadyPromise(writer).promise, | 1685 | | - pump, | 1686 | | - ignoreReadyRejection); | | 1705 | + parkOnReady(); | 1687 | 1706 | return; | 1688 | 1707 | } | 1689 | 1708 |
| @@ -1728,9 +1747,18 @@ function readableStreamPipeTo( | 1728 | 1747 | return; | 1729 | 1748 | } | 1730 | 1749 |
| 1731 | | - // Yield to microtask queue between batches to allow events/signals | 1732 | | - // to fire | 1733 | | - queueMicrotask(pump); | | 1750 | + // Park on backpressure directly: the ready hook resumes the pump | | 1751 | + // when a completed write clears it. | | 1752 | + if (dest[kState].backpressure) { | | 1753 | + parkOnReady(); | | 1754 | + return; | | 1755 | + } | | 1756 | + | | 1757 | + // Yield to the microtask queue between batches so completed-write | | 1758 | + // reactions and events/signals fire; a shared resolved promise | | 1759 | + // enqueues the continuation at the same position as queueMicrotask | | 1760 | + // without the per-batch scheduling overhead. | | 1761 | + PromisePrototypeThen(kResolvedPromise, pump); | 1734 | 1762 | return; | 1735 | 1763 | } | 1736 | 1764 |
| @@ -1742,7 +1770,7 @@ function readableStreamPipeTo( | 1742 | 1770 | // synchronous write during enqueue(). See WHATWG Streams spec | 1743 | 1771 | // "ReadableStreamPipeTo" step 15's "chunk steps". | 1744 | 1772 | pendingChunk = chunk; | 1745 | | - queueMicrotask(forwardChunk); | | 1773 | + PromisePrototypeThen(kResolvedPromise, forwardChunk); | 1746 | 1774 | }, | 1747 | 1775 | [kClose]() {}, | 1748 | 1776 | [kError]() {}, | @@ -1857,7 +1885,7 @@ function readableStreamDefaultTee(stream, cloneForBranch2) { | 1857 | 1885 | // The microtask is required by the spec (ReadableStreamTee's | 1858 | 1886 | // "chunk steps" queue one). | 1859 | 1887 | pendingChunk = value; | 1860 | | - queueMicrotask(forwardChunk); | | 1888 | + PromisePrototypeThen(kResolvedPromise, forwardChunk); | 1861 | 1889 | }, | 1862 | 1890 | [kClose]() { | 1863 | 1891 | // The `process.nextTick()` is not part of the spec. | @@ -2010,7 +2038,7 @@ function readableByteStreamTee(stream) { | 2010 | 2038 | defaultReadRequest ??= { | 2011 | 2039 | [kChunk](chunk) { | 2012 | 2040 | pendingChunk = chunk; | 2013 | | - queueMicrotask(forwardChunk); | | 2041 | + PromisePrototypeThen(kResolvedPromise, forwardChunk); | 2014 | 2042 | }, | 2015 | 2043 | [kClose]() { | 2016 | 2044 | reading = false; | @@ -2699,8 +2727,18 @@ function readableStreamDefaultControllerPull(controller) { | 2699 | 2727 | controller[kState].pullRejected = | 2700 | 2728 | (error) => readableStreamDefaultControllerError(controller, error); | 2701 | 2729 | } | 2702 | | - PromisePrototypeThen( | 2703 | | - controller[kState].pullAlgorithm(controller), | | 2730 | + // The pull algorithm may be a raw callback (a wrapped user source.pull | | 2731 | + // returns its result uncoerced; a synchronous throw surfaces here) or an | | 2732 | + // internal algorithm that always returns a promise; thenAlgorithmResult | | 2733 | + // handles both. | | 2734 | + let result; | | 2735 | + try { | | 2736 | + result = controller[kState].pullAlgorithm(controller); | | 2737 | + } catch (error) { | | 2738 | + result = PromiseReject(error); | | 2739 | + } | | 2740 | + thenAlgorithmResult( | | 2741 | + result, | 2704 | 2742 | controller[kState].pullFulfilled, | 2705 | 2743 | controller[kState].pullRejected); | 2706 | 2744 | } | @@ -2826,7 +2864,7 @@ function setupReadableStreamDefaultControllerFromSource( | 2826 | 2864 | FunctionPrototypeBind(start, source, controller) : | 2827 | 2865 | nonOpStart; | 2828 | 2866 | const pullAlgorithm = pull ? | 2829 | | - createPromiseCallback1Param('source.pull', pull, source) : | | 2867 | + createRawCallback1Param('source.pull', pull, source) : | 2830 | 2868 | nonOpPull; | 2831 | 2869 | const cancelAlgorithm = cancel ? | 2832 | 2870 | createPromiseCallback1Param('source.cancel', cancel, source) : | @@ -3519,8 +3557,15 @@ function readableByteStreamControllerCallPullIfNeeded(controller) { | 3519 | 3557 | controller[kState].pullRejected = | 3520 | 3558 | (error) => readableByteStreamControllerError(controller, error); | 3521 | 3559 | } | 3522 | | - PromisePrototypeThen( | 3523 | | - controller[kState].pullAlgorithm(controller), | | 3560 | + // See readableStreamDefaultControllerPull for the raw-callback contract. | | 3561 | + let result; | | 3562 | + try { | | 3563 | + result = controller[kState].pullAlgorithm(controller); | | 3564 | + } catch (error) { | | 3565 | + result = PromiseReject(error); | | 3566 | + } | | 3567 | + thenAlgorithmResult( | | 3568 | + result, | 3524 | 3569 | controller[kState].pullFulfilled, | 3525 | 3570 | controller[kState].pullRejected); | 3526 | 3571 | } | @@ -3700,7 +3745,7 @@ function setupReadableByteStreamControllerFromSource( | 3700 | 3745 | FunctionPrototypeBind(start, source, controller) : | 3701 | 3746 | nonOpStart; | 3702 | 3747 | const pullAlgorithm = pull ? | 3703 | | - createPromiseCallback1Param('source.pull', pull, source) : | | 3748 | + createRawCallback1Param('source.pull', pull, source) : | 3704 | 3749 | nonOpPull; | 3705 | 3750 | const cancelAlgorithm = cancel ? | 3706 | 3751 | createPromiseCallback1Param('source.cancel', cancel, source) : | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/webstreams/util.js +43-2Lines changed: 43 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -338,6 +338,40 @@ function createPromiseCallbackNoParams(name, fn, thisArg) { | 338 | 338 | return async () => FunctionPrototypeCall(fn, thisArg); | 339 | 339 | } | 340 | 340 |
| | 341 | +// Raw variants that skip the async wrapper's implicit result promise. | | 342 | +// Consumers of a raw callback invoke it inside try/catch and route the | | 343 | +// result through thenAlgorithmResult() below. | | 344 | +function createRawCallback1Param(name, fn, thisArg) { | | 345 | + validateFunction(fn, name); | | 346 | + return (arg) => FunctionPrototypeCall(fn, thisArg, arg); | | 347 | +} | | 348 | + | | 349 | +function createRawCallback2Params(name, fn, thisArg) { | | 350 | + validateFunction(fn, name); | | 351 | + return (arg1, arg2) => FunctionPrototypeCall(fn, thisArg, arg1, arg2); | | 352 | +} | | 353 | + | | 354 | +// A single shared, forever-resolved promise used to enqueue a reaction at | | 355 | +// the next microtask checkpoint without allocating a fresh promise. | | 356 | +const kResolvedPromise = PromiseResolve(); | | 357 | + | | 358 | +// Wires the (possibly non-thenable) result of an underlying algorithm | | 359 | +// callback to its fulfilled/rejected continuations. A non-thenable result | | 360 | +// means fulfillment is guaranteed and no then() lookup is observable, so | | 361 | +// the fulfillment step is enqueued directly at the exact microtask | | 362 | +// position the coerced promise's reaction would have had, skipping the | | 363 | +// per-chunk promise allocation. For thenable results PromiseResolve() | | 364 | +// matches the spec's "a promise resolved with" conversion (identity for | | 365 | +// native promises). | | 366 | +function thenAlgorithmResult(result, onFulfilled, onRejected) { | | 367 | + if (result === null || | | 368 | + (typeof result !== 'object' && typeof result !== 'function')) { | | 369 | + PromisePrototypeThen(kResolvedPromise, onFulfilled); | | 370 | + } else { | | 371 | + PromisePrototypeThen(PromiseResolve(result), onFulfilled, onRejected); | | 372 | + } | | 373 | +} | | 374 | + | 341 | 375 | function createPromiseCallback1Param(name, fn, thisArg) { | 342 | 376 | validateFunction(fn, name); | 343 | 377 | return async (arg) => FunctionPrototypeCall(fn, thisArg, arg); | @@ -386,11 +420,14 @@ async function nonOpFlush() {} | 386 | 420 |
| 387 | 421 | function nonOpStart() {} | 388 | 422 |
| 389 | | -async function nonOpPull() {} | | 423 | +// nonOpPull and nonOpWrite are raw callbacks (see createRawCallback*): | | 424 | +// their non-thenable return takes the allocation-free fast path in | | 425 | +// thenAlgorithmResult(). | | 426 | +function nonOpPull() {} | 390 | 427 |
| 391 | 428 | async function nonOpCancel() {} | 392 | 429 |
| 393 | | -async function nonOpWrite() {} | | 430 | +function nonOpWrite() {} | 394 | 431 |
| 395 | 432 | let transfer; | 396 | 433 | function lazyTransfer() { | @@ -411,6 +448,8 @@ module.exports = { | 411 | 448 | createPromiseCallbackNoParams, | 412 | 449 | createPromiseCallback1Param, | 413 | 450 | createPromiseCallback2Params, | | 451 | + createRawCallback1Param, | | 452 | + createRawCallback2Params, | 414 | 453 | customInspect, | 415 | 454 | defaultSizeAlgorithm, | 416 | 455 | dequeueValue, | @@ -421,6 +460,7 @@ module.exports = { | 421 | 460 | isBrandCheck, | 422 | 461 | isPromisePending, | 423 | 462 | kEmptyQueue, | | 463 | + kResolvedPromise, | 424 | 464 | kState, | 425 | 465 | kType, | 426 | 466 | lazyTransfer, | @@ -435,4 +475,5 @@ module.exports = { | 435 | 475 | resetQueue, | 436 | 476 | resolvedRecord, | 437 | 477 | setPromiseHandled, | | 478 | + thenAlgorithmResult, | 438 | 479 | }; |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/webstreams/writablestream.js +21-6Lines changed: 21 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -56,7 +56,7 @@ const { | 56 | 56 | Queue, | 57 | 57 | createPromiseCallbackNoParams, | 58 | 58 | createPromiseCallback1Param, | 59 | | - createPromiseCallback2Params, | | 59 | + createRawCallback2Params, | 60 | 60 | customInspect, | 61 | 61 | defaultSizeAlgorithm, | 62 | 62 | dequeueValue, | @@ -78,6 +78,7 @@ const { | 78 | 78 | resetQueue, | 79 | 79 | resolvedRecord, | 80 | 80 | setPromiseHandled, | | 81 | + thenAlgorithmResult, | 81 | 82 | } = require('internal/webstreams/util'); | 82 | 83 |
| 83 | 84 | const { | @@ -769,7 +770,12 @@ function writableStreamUpdateBackpressure(controller, streamState) { | 769 | 770 | const backpressure = | 770 | 771 | controllerState.highWaterMark - controllerState.queueTotalSize <= 0; | 771 | 772 | const writer = streamState.writer; | 772 | | - if (writer !== undefined && streamState.backpressure !== backpressure) { | | 773 | + const changed = streamState.backpressure !== backpressure; | | 774 | + // The state field is published before the ready record is resolved so | | 775 | + // that a ready resolve hook (pipeTo's pump continuation) observes the | | 776 | + // new value. | | 777 | + streamState.backpressure = backpressure; | | 778 | + if (writer !== undefined && changed) { | 773 | 779 | if (backpressure) { | 774 | 780 | // The spec replaces [[readyPromise]] with a fresh pending promise; | 775 | 781 | // dropping the cache lets the next observation derive it. | @@ -778,7 +784,6 @@ function writableStreamUpdateBackpressure(controller, streamState) { | 778 | 784 | writer[kState].ready?.resolve(); | 779 | 785 | } | 780 | 786 | } | 781 | | - streamState.backpressure = backpressure; | 782 | 787 | } | 783 | 788 |
| 784 | 789 | function writableStreamStartErroring(stream, reason) { | @@ -1197,8 +1202,18 @@ function writableStreamDefaultControllerProcessWrite(controller, chunk) { | 1197 | 1202 | }; | 1198 | 1203 | } | 1199 | 1204 |
| 1200 | | - PromisePrototypeThen( | 1201 | | - writeAlgorithm(chunk, controller), | | 1205 | + // The write algorithm may be a raw callback (a wrapped user sink.write | | 1206 | + // returns its result uncoerced; a synchronous throw surfaces here) or an | | 1207 | + // internal algorithm that always returns a promise; thenAlgorithmResult | | 1208 | + // handles both. | | 1209 | + let result; | | 1210 | + try { | | 1211 | + result = writeAlgorithm(chunk, controller); | | 1212 | + } catch (error) { | | 1213 | + result = PromiseReject(error); | | 1214 | + } | | 1215 | + thenAlgorithmResult( | | 1216 | + result, | 1202 | 1217 | controller[kState].writeFulfilled, | 1203 | 1218 | controller[kState].writeRejected); | 1204 | 1219 | } | @@ -1323,7 +1338,7 @@ function setupWritableStreamDefaultControllerFromSink( | 1323 | 1338 | FunctionPrototypeBind(start, sink, controller) : | 1324 | 1339 | nonOpStart; | 1325 | 1340 | const writeAlgorithm = write ? | 1326 | | - createPromiseCallback2Params('sink.write', write, sink) : | | 1341 | + createRawCallback2Params('sink.write', write, sink) : | 1327 | 1342 | nonOpWrite; | 1328 | 1343 | const closeAlgorithm = close ? | 1329 | 1344 | createPromiseCallbackNoParams('sink.close', close, sink) : | |
|