Repository navigation
Restore the write lock and retry errors that follow an ECANCELED end callback - #6
Merged
Merged
Conversation
Removing the lock (bb8eca9 backport) made `write()` / `end(data)` on a stream that already has a `body`, or whose method cannot have one, put the bytes on the wire after the request instead of throwing. On a keep-alive connection they reach the server as another request. Restore the 11.8.6 lock. `stream.pipeline()` crashed on Node.js 17.3+ because it calls `end()` after the lock is re-applied; ending without a payload writes nothing, so it is allowed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZiY7VMgg1nEMkCezwMXEC
On Node.js 24.20+, a request destroyed without an error (e.g. a proxy agent destroying its socket) reports `ECANCELED` through the `end()` callback before its `error` event carries the retryable `ECONNRESET`. Handling the first one made the request fail without retries. Wait for the request's `close` event, which follows its `error`, and only then report the `end()` error if nothing else did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZiY7VMgg1nEMkCezwMXEC
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
This was referenced Sep 15, 2026
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.
Fixes the two regressions an adversarial review found in the
v11branch (11.8.6..50eb9aa) before releasing 11.8.8.1. Restore the stream write lock, allowing
end()without a payloadRegression from #2. Removing the lock (backport of Got 12's bb8eca9) made
write()/end(data)on a stream that already has abody/json/form, or whose method can't have a body (GET/HEAD), send the bytes after the request instead of throwing. On a keep-alive connection the server reads them as a separate request:A stray
write()after the request started also destroyed a request that would have succeeded (ERR_STREAM_WRITE_AFTER_END).Fix: restore the 11.8.6 lock. The lock is only a problem for Node.js 17.3+'s
stream.pipeline(), which callsend()from its own listener after the lock is back on. Ending without a payload writes nothing, so the lockedend()now allows it and still throws when given data.Tests (
test/stream.ts):data written after the body is never sent: the raw request injection throws, and the server receives exactly one request;ending a stream without a payload does not throw;piping server request to Got proxies also headersstill covers thepipeline()crash.2. Retry the request error that follows an
ECANCELEDend callbackGap in #1, Node.js 24.20+ only. When a request is destroyed without an error (a proxy agent destroying its socket,
req.destroy()), Node reportsECANCELEDthrough theend()callback before the request's'error'event carries the retryableECONNRESET. Since #1 handles the first error it sees, the request failed without retries.Event order measured with plain
http(upload stalled mid-body):endcb:ok > error:ECONNRESET > closeerror > closeendcb:ECANCELED > error:ECONNRESET > closeerror > endcb:ECANCELED > closeFix: when
end()reportsECANCELEDorERR_STREAM_DESTROYEDand the request isn't closed yet, report it on the request's'close'instead. By then the request's own'error'has already gone through_beforeError, so the deferred call does nothing. If no error ever comes,'close'still settles the request, so it can't hang.Tests (
test/retry.ts):ECANCELEDthenECONNRESETthenclose: retried twice withECONNRESET;ECANCELEDthenclosewith no error: rejects withECANCELEDon the first attempt (no hang);ECANCELED(no hang);ECONNRESET.Verification
Each new test fails without its fix:
v11source (no lock) on 24.20end()tests passECANCELEDretry test and the real-socket test failFull suite locally with this branch (macOS,
OPENSSL_BINpointing at OpenSSL 3): Node.js 24.20.0 and 26.8.2 both 543 tests, 540 pass, 0 fail, 3 skipped.xoon the changed files: no errors, same 18 warnings as the 11.8.6 baseline.🤖 Generated with Claude Code
https://claude.ai/code/session_01VZiY7VMgg1nEMkCezwMXEC
Note
High Risk
Changes core HTTP upload/stream behavior and retry error routing; the write lock is security-relevant (request smuggling), and
_finalerror timing affects all retried uploads on newer Node versions.Overview
Restores two regressions in Got’s stream
Requestduplex: payload write locking and retry behavior afterECANCELEDon upload end.Write lock: Reintroduces
_lockWrite/_unlockWriteso streams withbody/json/form, or GET/HEAD without a writable body, reject extrawrite()/end(data)with "The payload has been already provided"—blocking keep-alive request smuggling when bytes would otherwise follow the real request. Locking is coordinated with piped sources and internal send paths;end()with no chunk stays allowed sostream.pipeline()on Node 17.3+ still works.Retry /
ECANCELED: On Node 24.20+, a destroyed upload can surfaceECANCELEDfromClientRequest.end()before a retryableerror(e.g.ECONNRESET). Those codes are deferred until the request'close'event when the request is not already closed, so the real error drives retries and uploads do not fail without retrying or hang when no follow-up error arrives.Tests cover lock enforcement (including injection-style writes), bare
end(), mockedECANCELED→ECONNRESETretry paths, and a large PUT with mid-upload socket destroy.Reviewed by Cursor Bugbot for commit 1d4b17a. Bugbot is set up for automated code reviews on this repo. Configure here.