Repository navigation
fix: stop Request.pipe() crashing the process on request errors - #1918
Merged
Merged
Conversation
dhensby
force-pushed
the
fix/request-pipe-error-crash
branch
2 times, most recently
from
October 13, 2026 11:22
89c089e to
6082d4d
Compare
dhensby
force-pushed
the
fix/request-pipe-error-crash
branch
from
October 13, 2026 16:21
6082d4d to
688b9ab
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The new destroy implementation discards caller-provided stream cleanup hooks.
1 open finding
What changed in this PR
Fixes request errors crashing Request.pipe() and ensures destroyed readable streams release back-pressured requests.
Changes:
- Destroys streams with the first request error and forwards pipe errors.
- Resumes unfinished, non-cancelled requests when their readable stream is destroyed.
- Adds driver tests and documents stream error behavior.
| File | Description |
|---|---|
lib/base/request.js |
Implements stream destruction, recovery, and error forwarding. |
README.md |
Documents error and pipeline behavior. |
test/common/unit.js |
Tests readable destruction and error handling. |
test/common/tests.js |
Adds shared integration scenarios. |
test/tedious/tedious.js |
Enables new tedious tests. |
test/msnodesqlv8/msnodesqlv8.js |
Adds version-gated msnodesqlv8 coverage. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
toReadableStream() pauses the request whenever the stream's buffer is full and resumes it only when the stream asks for more. A stream that is destroyed never asks again. This happens, for example, when pipeline() tears down every stream after a later one fails. The request then stayed paused forever and kept its pooled connection. When the stream is destroyed before the request is done, resume the request and stop pausing it. It runs to the end, its remaining rows are dropped, and its connection is released. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pipe() pipes an internal readable stream into the destination and returns the destination, so nothing can listen for errors on the readable stream. toReadableStream() re-emitted each request error on it, and Readable.prototype.pipe() does not forward a source's errors. So every error a piped request hit, such as a SQL error, a timeout or a cancel, was thrown from inside the driver's event handler and crashed the process. A listener on the destination or on the request did not help. pipe() now destroys the destination with the request's first error, as pipeline() does, so the destination's 'error' listener receives it. toReadableStream() now destroys its stream with that error instead of emitting it, which follows the stream contract. A request can emit more than one error and then carry on to 'done'. Later errors and rows can no longer reach a stream that has already failed: the errors stay on the request and the rows are dropped. A cancelled request is not resumed when its stream is destroyed. Its driver is already stopping it. tedious resumes a cancelled request itself, and msnodesqlv8 reports the cancel after the query is gone. Resuming the request then would make msnodesqlv8 read the freed statement on a connection that is already back in the pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the destination of Request.pipe() fails or closes before the request finishes, Readable.prototype.pipe() only unpipes from it. The readable stream that pipe() puts in front of the destination is internal, so nothing destroys it. It stays full, the request stays paused, and the request keeps its pooled connection for good. Piping into a destination that has already been destroyed does the same. pipe() now destroys that stream when the destination unpipes before the stream has ended, or is already destroyed when pipe() is called, as pipeline() would. The request then runs to the end, its remaining rows are dropped, and its connection is released. pipe() listens for 'unpipe' rather than 'close'. A destination created with autoDestroy or emitClose turned off never emits 'close'. It also does not use finished(), because finished() adds an 'error' listener, which would stop an unhandled destination error from being thrown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dhensby
force-pushed
the
fix/request-pipe-error-crash
branch
from
October 13, 2026 19:40
9a7ebf6 to
d039a07
Compare
dhensby
marked this pull request as ready for review
October 13, 2026 19:40
|
🎉 This PR is included in version 12.7.7 🎉 The release is available on: Your semantic-release bot 📦🚀 |
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.


What's wrong
Request.pipe()crashes the process on any request error: a SQL error, a timeout or a cancel. It happens with both drivers.pipe()puts an internalReadablein front of the destination and returns the destination, so callers can't listen on theReadable.toReadableStream()re-emitted each request error on thatReadable, andReadable.prototype.pipe()doesn't forward a source's errors to the destination. So the'error'had no listener and was thrown from inside the driver's event handler. Error listeners on the destination (as the README example shows) or on the request didn't help:The original change (616c745, "Enable pipe to support back pressure and errors") meant to forward errors to the stream, but the code never did.
What changes
fix: resume a request when its readable stream is destroyedFixing the crash depends on this.
toReadableStream()pauses the request when the stream's buffer is full and resumes it only when the stream reads again. A destroyed stream never reads again, so the request stayed paused forever and kept its pooled connection. This already happened on master withpipeline(request.toReadableStream(), dest)whendestfailed part way through. I reproduced it on tedious: the request hangs and the pool can't close. Now, when the stream is destroyed before the request is done, the request is resumed and no longer paused. It runs to the end, its remaining rows are dropped and its connection is released.destroyhook passed intoReadableStream()'s stream options still runs, after the stream's own, with the stream asthis.nulloptions work as before.cancel()made after that doesn't stop it, and it runs to the end. The README says so, in line with its existing note that those versions can't cancel a running request. A cancel made while the request is still paused works as before.fix: pass request errors to the destination of Request.pipe()pipe()destroys the destination with the request's first error, aspipeline()does. The destination's'error'listener gets the error and'finish'isn't emitted.toReadableStream()destroys its stream with the first error instead of callingemit('error')by hand, which follows the stream contract.pipeline()users see no difference.'done'. A second error is a no-op on a destroyed stream, so it stays on the request. Rows after the error and the finalpush(null)are dropped. Neither can throw or pause the request again.ECANCEL, the destroyed stream does not resume it. tedious resumes a cancelled request itself. Since fix: support the msnodesqlv8 5.6 error, cancel and info events #1917, msnodesqlv8 reports a cancelled stream once the query is gone and its connection is back in the pool. Resuming it at that point made msnodesqlv8 read the freed statement, which raisedcannot call dead statement/Statement not foundand sent extradone/freeevents to a connection already back in the pool. I confirmed both the stray events and the fix on Windows CI for msnodesqlv8 2.7–5.6.destroy(err)overwritableStream.emit('error', err). Emitting by hand bypasses the Writable's state machine: the stream isn't marked errored, it still accepts writes, and it can still finish.pipe()andtoReadableStream(). For chains of streams it recommendsstream.pipeline()withtoReadableStream(), sincepipe()only passes errors to the stream it is given.fix: release a piped request when its destination failsThis one also happens on master. When the destination fails or closes first,
Readable.prototype.pipe()only unpipes from it. Nothing destroys the internal stream, so the request stays paused and keeps its connection for good. Piping into a destination that is already destroyed does the same. I reproduced both on tedious.pipe()now destroys its internal stream when the destination unpipes before the stream ends, or is already destroyed. The request then runs to the end, its remaining rows are dropped and its connection is released.'unpipe'. A destination created withautoDestroyoremitCloseturned off never emits'close'. Andstream.finished()adds an'error'listener, which would stop an unhandled destination error from being thrown.pipe()adds no'error'listener of its own.msnodesqlv8
On master, a msnodesqlv8 stream-mode request does not complete after an error. That has nothing to do with
pipe(): the same happens withrequest.stream = trueand no pipe. I confirmed it on Windows CI with SQL Server 2022, tracing the raw driver events on every msnodesqlv8 line CI uses (2.7.0, 3.1.0, 4.5.0, 5.2.1, 5.6.0):error(more=false)thenfree, and neverdone. The adapter only completes a stream ondone, so the request keeps its connection.more=trueand the batch goes on todone. An error that ends the batch still gets nodone.pipe()no longer crashes on any msnodesqlv8 version. The destination gets the first error on all of them. Whether the request then completes depends only on the behaviour above.pipe()and plain stream mode gave the same result in every scenario.done, through the adapter'sisPaused()workaround.So the msnodesqlv8 suite:
query with pipe and an error that ends the batchon every version;query with pipe and an error that does not end the batchfrom 5.6 only, and skips it with a reason before that.Both can run on every version once msnodesqlv8 streams complete after an error (settling on
freeas well asdone). The pipe logic lives inlib/base/request.jsand doesn't depend on the driver; the new unit tests cover it without a database.Testing
query with pipe and an error that ends the batchquery with pipe and an error that does not end the batchquery with toReadableStream and a failing destinationquery with pipe and a failing destination(it checks the request was held back by the full stream when the destination failed)query with pipe into a destination that is already destroyedUncaught RequestError. The others time out with the connection still borrowed.Request readable stream. Each was mutation-tested against the change it covers. They pass on Node 18, 20, 22 and 24.npm run lintandnpm run test-unitpass (171).npm run test-tediouslocally against Azure SQL Edge: 169 passing. The 3 failures (user defined typesand two CLR-type bulk loads) are because SQL Edge has no CLR. They fail the same way on master.'done', and the connection went back to the pool.🤖 Generated with Claude Code