Rework node.js stream utilities (readAsync, sliceStream, readSubstream, Splice) - #570
Merged
Conversation
Also export new `sliceStream` and `readAsync` functions.
Collaborator
Author
|
@lifengl This is what I would still like to merge (eventually), once we figure out what causes your scenarios to fail with it and add tests to demonstrate it within the repo. |
# Conflicts: # azure-pipelines/node.yml # src/nerdbank-streams/package.json # src/nerdbank-streams/src/FullDuplexStream.ts # src/nerdbank-streams/src/Utilities.ts # src/nerdbank-streams/src/index.ts # src/nerdbank-streams/src/tests/FullDuplexStream.spec.ts # src/nerdbank-streams/src/tests/MultiplexingStream.Interop.spec.ts # src/nerdbank-streams/src/tests/Substream.spec.ts # src/nerdbank-streams/yarn.lock
This rewrites the original PR's implementation to avoid two node.js stream
gotchas that silently broke consumer scenarios.
1. `readAsync` no longer reads via flowing mode (`resume()` + `once('data')`
+ `pause()`). That approach permanently left `readableFlowing === false`,
so any consumer that later attached a `data` handler (or piped) received
nothing at all -- a silent hang. It now reads via the `readable` event,
which node restores to its prior state once the last such handler is
removed. It also no longer throws when the stream isn't already paused.
2. `sliceStream`/`getBufferFrom` no longer `unshift` an unconsumed remainder
back onto the source. Because node drains its `nextTick` queue before
promise microtasks, the `unshift` following an `await` can run after the
source has already emitted `end`, which silently drops the remainder (or
raises ERR_STREAM_UNSHIFT_AFTER_END_EVENT). Reads are now capped by
`readableLength` so that no more bytes are ever consumed than were asked
for, making `unshift` unnecessary.
Other changes:
* `readSubstream` now streams each length-prefixed chunk incrementally
instead of buffering it whole, which was the original goal of #570.
* Failures inside the async `_read` of `sliceStream`/`readSubstream` now
destroy the stream with the error rather than becoming an unhandled
promise rejection that hangs the reader forever.
* In-flight reads are cancelled when `sliceStream`/`readSubstream` is
destroyed, so they no longer steal data from the underlying stream.
* `FullDuplexStream.Splice` returns a real `Duplex` instead of one whose
`read` and `on` members were monkey-patched onto the source stream. This
makes `unshift`, `readableLength`, `readableEnded`, `off`, `pipe` and
friends behave correctly. It pulls from the source on demand so that data
already available is still readable synchronously, honors back-pressure,
and forwards errors and premature close from both underlying streams.
Note the one intentional behavior change: events raised on a spliced duplex
are now its own events rather than the source stream's, so `finish` is now
raised on the stream that was ended rather than on its peer.
Tests: 120 -> 184.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
AArnott
marked this pull request as ready for review
July 26, 2026 03:49
There was a problem hiding this comment.
Pull request overview
This PR reworks the Node.js stream helper utilities to avoid over-consumption and flowing-mode hazards, improves substream/slice streaming behavior, and updates FullDuplexStream.Splice to return a proper Duplex that preserves backpressure and standard stream semantics.
Changes:
- Reimplements
readAsync/buffer reads to use paused-mode (readable) and cap consumption to requested bytes; updatesgetBufferFromaccordingly. - Reworks
sliceStreamandreadSubstreamto stream incrementally, add cancellation on destroy, and avoidunshift/post-endhazards. - Replaces
FullDuplexStream.Splicemonkey-patching with a real pull-basedDuplexand expands test coverage for these behaviors.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/nerdbank-streams/src/Utilities.ts | New readAsync + capped read helper; updated sliceStream/readSubstream and getBufferFrom to avoid over-consumption and improve cancellation/error handling. |
| src/nerdbank-streams/src/tests/Utilities.spec.ts | Adds focused regression and behavior tests for readAsync, sliceStream, and getBufferFrom. |
| src/nerdbank-streams/src/tests/Substream.spec.ts | Adds streaming/interop/cancellation/error-propagation tests for readSubstream. |
| src/nerdbank-streams/src/tests/FullDuplexStream.spec.ts | Updates finish-event expectations and adds extensive coverage for Splice semantics (backpressure, async iteration, pipe, unshift, interop). |
| src/nerdbank-streams/src/index.ts | Exports readAsync and sliceStream from the package entrypoint. |
| src/nerdbank-streams/src/FullDuplexStream.ts | Implements pull-based Splice that returns a real Duplex and forwards end/close/error appropriately. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+98
to
+102
| destroy(error, callback) { | ||
| // Stop pumping, but leave the 'error' handlers attached. If either underlying stream | ||
| // fails later, forwarding the error to this (already destroyed) stream harmlessly | ||
| // absorbs it instead of crashing the process with an unhandled 'error' event. | ||
| detachReadableListener() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resurrects this long-stale PR: merged with
mainand reworked to fix the hazards that made it break consumer scenarios.What went wrong originally
I reproduced two silent data/liveness bugs in the original implementation:
readAsyncleft streams permanently paused. It read via flowing mode (stream.resume()+once('data')+stream.pause()). Node tracksreadableFlowingasnull(never flowed) /true/false(explicitly paused), and.on('data')only auto-resumes when it is notfalse. So after a singlereadAsynccall, the stream was stuck atfalseand any consumer that subsequently attached adatahandler received nothing, forever:This is almost certainly what broke @lifengl's scenarios. It also threw
'Stream must not be in flowing mode.'synchronously from a promise-returning function.unshiftof the unconsumed remainder could run afterend.sliceStream/getBufferFromread a whole chunk andunshifted what they didn't need. Node drains itsnextTickqueue before promise microtasks, so when the read was satisfied by an already-ended stream, theendevent fired before theawaitresumed — and theunshiftthen either silently dropped the remainder or raisedERR_STREAM_UNSHIFT_AFTER_END_EVENT.What this version does instead
readAsyncuses thereadableevent (paused mode). Node restores the prior flowing state when the lastreadablehandler is removed, so it has no lasting effect on the stream. No flowing-mode check needed.readableLengthso no more bytes are ever consumed than were requested.unshiftis gone entirely.readSubstreamstreams each length-prefixed chunk incrementally instead of buffering it whole (the original goal of the PR)._readofsliceStream/readSubstreamnowdestroy()the stream instead of becoming an unhandled promise rejection that hangs the reader forever.sliceStream/readSubstreamis destroyed, so a discarded wrapper no longer steals data from the underlying stream.FullDuplexStream.Splicereturns a realDuplexrather than one whosereadandonmembers were monkey-patched onto the source stream, sounshift,readableLength,readableEnded,off,pipe, async iteration etc. all work. It pulls from the source on demand (rather than push-pumping viadata), which preserves back-pressure, avoids eagerly draining the source, and keeps data that is already available synchronously readable. Errors and premature close from both underlying streams are forwarded.plexerpackage the original PR pulled in is not used.Behavior change to be aware of
Events raised on a spliced duplex are now its own events instead of the source stream's. Concretely,
finishis now raised on the stream that was ended rather than on its peer.FullDuplexStream.spec.tswas updated accordingly.Testing
Test count went from 120 to 184, including regression tests for both hazards above (flowing-state preservation, no over-consumption), plus coverage of
pipe, async iteration, back-pressure, cancellation, listener leaks, error propagation, premature close, destroyed-wrapper cleanup, truncated substreams, and vscode-jsonrpc over a spliced duplex.All tests pass, including the .NET interop suite (v1/v2/v3), and
eslintis clean.