Skip to content

Rework node.js stream utilities (readAsync, sliceStream, readSubstream, Splice) - #570

Merged
AArnott merged 5 commits into
mainfrom
nodejsStreamsWork
Jul 26, 2026
Merged

Rework node.js stream utilities (readAsync, sliceStream, readSubstream, Splice)#570
AArnott merged 5 commits into
mainfrom
nodejsStreamsWork

Conversation

@AArnott

@AArnott AArnott commented Dec 14, 2022

Copy link
Copy Markdown
Collaborator

Resurrects this long-stale PR: merged with main and 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:

  1. readAsync left streams permanently paused. It read via flowing mode (stream.resume() + once('data') + stream.pause()). Node tracks readableFlowing as null (never flowed) / true / false (explicitly paused), and .on('data') only auto-resumes when it is not false. So after a single readAsync call, the stream was stuck at false and any consumer that subsequently attached a data handler received nothing, forever:

    initial readableFlowing: null
    read: <Buffer 01 02 03>
    readableFlowing after readAsync: false
    data received by later .on('data') consumer: null   // <-- silent hang
    

    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.

  2. unshift of the unconsumed remainder could run after end. sliceStream/getBufferFrom read a whole chunk and unshifted what they didn't need. Node drains its nextTick queue before promise microtasks, so when the read was satisfied by an already-ended stream, the end event fired before the await resumed — and the unshift then either silently dropped the remainder or raised ERR_STREAM_UNSHIFT_AFTER_END_EVENT.

What this version does instead

  • readAsync uses the readable event (paused mode). Node restores the prior flowing state when the last readable handler is removed, so it has no lasting effect on the stream. No flowing-mode check needed.
  • Reads are capped by readableLength so no more bytes are ever consumed than were requested. unshift is gone entirely.
  • A synchronous fast path avoids resuming on a later microtask when data is already buffered.
  • readSubstream streams each length-prefixed chunk incrementally instead of buffering it whole (the original goal of the PR).
  • Errors thrown inside the async _read of sliceStream/readSubstream now destroy() the stream instead of becoming an unhandled promise rejection that hangs the reader forever.
  • In-flight reads are cancelled when sliceStream/readSubstream is destroyed, so a discarded wrapper no longer steals data from the underlying stream.
  • FullDuplexStream.Splice returns a real Duplex rather than one whose read and on members were monkey-patched onto the source stream, so unshift, readableLength, readableEnded, off, pipe, async iteration etc. all work. It pulls from the source on demand (rather than push-pumping via data), 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.
  • No new dependency: the plexer package 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, finish is now raised on the stream that was ended rather than on its peer. FullDuplexStream.spec.ts was 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 eslint is clean.

@AArnott

AArnott commented Dec 14, 2022

Copy link
Copy Markdown
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.

AArnott and others added 2 commits July 25, 2026 19:03
# 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 AArnott changed the title Node.js streams work Rework node.js stream utilities (readAsync, sliceStream, readSubstream, Splice) Jul 26, 2026
@AArnott
AArnott marked this pull request as ready for review July 26, 2026 03:49
@AArnott
AArnott requested a review from Copilot July 26, 2026 03:49
@AArnott
AArnott added this pull request to the merge queue Jul 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; updates getBufferFrom accordingly.
  • Reworks sliceStream and readSubstream to stream incrementally, add cancellation on destroy, and avoid unshift/post-end hazards.
  • Replaces FullDuplexStream.Splice monkey-patching with a real pull-based Duplex and 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()
Merged via the queue into main with commit 52a0822 Jul 26, 2026
12 of 13 checks passed
@AArnott
AArnott deleted the nodejsStreamsWork branch July 26, 2026 03:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants