Repository navigation
Fix edge cases from the review of the write lock and ECANCELED handling - #7
Conversation
…ling - Report an `ECANCELED`/`ERR_STREAM_DESTROYED` end callback error from `setImmediate` instead of waiting for `close`. Node.js emits the request `error` from `process.nextTick`, so it still wins, and a socket that never emits `close` no longer leaves the request unsettled. - Emit `upload-complete` on the request that finished the upload. `this[kRequest]` is removed while a redirect runs, so an upload finishing during an async `beforeRedirect` hook threw an uncaught TypeError (also in 11.8.6). - Keep throwing on `end()` while a piped source has not ended, as 11.8.6 did, instead of silently truncating the upload. - Allow ending a locked stream with an empty string or buffer. 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 |
A third review found that reporting `ECANCELED` from `setImmediate` loses
the retryable error when the socket closes asynchronously, and that
`ERR_SOCKET_CLOSED` (socket destroyed on connect) was reported before the
request's `ECONNRESET` and never retried. Measured on Node.js 24.20 and 26:
with a socket destroyed on connect, the request `error` arrives after
`setImmediate` even on plain TCP, but always before `close`.
- Defer `ECANCELED`, `ERR_STREAM_DESTROYED` and `ERR_SOCKET_CLOSED` end
callback errors until the request's `close`, with a 1s fallback for
sockets that never emit it.
- Empty chunks count as a payload again on a locked stream: `end('')` after
the request started failed later with `ERR_STREAM_WRITE_AFTER_END`
instead of throwing, as 11.8.6 does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZiY7VMgg1nEMkCezwMXEC
A fourth review found that http2-wrapper emits `close` before the `ECANCELED` end callback, so the deferral waited for its 1s fallback and kept the process alive after failed or cancelled http2 uploads (exit at 1021ms instead of 18ms). - Report the end callback error immediately when the request is already `closed` (`ClientRequest#closed` and `Writable#closed`). - Cancel the fallback timer when this stream closes, so a request settled by another error does not hold the event loop. The timer stays referenced: unref'ing it lets a request that never settles exit the process silently. - Keep `complete` enumerable on tracked clones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZiY7VMgg1nEMkCezwMXEC
A fifth review found that a bare `end()` was accepted while the upload body was still streaming, truncating it or leaving the request unsettled (11.8.6 threw): - a stream `body` option is only piped once the request starts, so it was not tracked before that; - legacy streams, including form-data's CombinedStream, have no `readableEnded`, so they were never considered active. Track piped sources, and a stream `body` from the constructor, until they emit `end` or `close`, with listeners registered first so `stream.pipeline()` can still end the stream from its own `end` listener. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZiY7VMgg1nEMkCezwMXEC
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit fc91f02. Configure here.
A sixth review found that the `end`/`close` listeners added to piped sources were only removed when the source itself ended or closed. A long-lived source unpiped from got streams (explicitly, or on error, timeout or destroy) kept every Request reachable and triggered MaxListenersExceededWarning. Keep each source's cleanup in a Map and run it on `unpipe` and when this stream closes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VZiY7VMgg1nEMkCezwMXEC

Fixes the findings from the second to fifth cold reviews (of #6, then of this PR's commits).
v11already has the 11.8.8 version bump (#5), so this must merge before 11.8.8 is published.1. Socket-closed end errors are reported on the request's
close(findings from two reviews)Problems:
ECANCELEDend callback #6 waited for the request'scloseafter anECANCELED/ERR_STREAM_DESTROYEDend callback, so a custom socket that never emitsclose(emitClose: false) left the request unsettled.setImmediate, lost the retryable error when a socket closes asynchronously.ERR_SOCKET_CLOSED(socket destroyed onconnect, Node.js 24.20+) was reported at once and never retried.Measured with plain
httpon Node.js 24.20 and 26.8.2:endcb:ECANCELED > error:ECONNRESET > close > setImmediateendcb:ERR_SOCKET_CLOSED > setImmediate > error:ECONNRESET > closeendcb:ERR_SOCKET_CLOSED > error:ECONNRESET > setImmediate > closeSo
setImmediateis not enough, but the request'serroralways comes before itsclose.Fix: for
ECANCELED,ERR_STREAM_DESTROYEDandERR_SOCKET_CLOSED, report the end callback error on the request'sclose, with a 1 s fallback timer for sockets that never emit it._beforeErrorignores the late call if the request's own error was already handled.Tests (
test/retry.ts):retries an upload whose socket is destroyed when it connectsretries a request without a body whose socket is destroyed when it connectsretries an upload whose socket closes asynchronously after being destroyed(a custom Duplex socket whose_destroyfinishes 5 ms later)rejects with ECANCELED when the destroyed request emits neither error nor close(settled by the fallback)2. Important, also in 11.8.6: uncaught
TypeErrorwhen the upload finishes during a redirectProblem: redirect handling deletes
this[kRequest]before awaitingbeforeRedirect. An upload that finished in that window hitthis[kRequest]!.emit('upload-complete')and threw an uncaught exception.Fix: emit on the request that finished the upload.
Test:
an upload finishing while a redirect is in progress does not throw(redirects.ts). A 32 MB PUT gets a 307, the server reads the body 100 ms later, and an asyncbeforeRedirecthook takes 500 ms. Without the fix the test file dies withUncaught exception.3. Minor, compared with 11.8.6:
end()while a source is still piped silently truncated the uploadProblem: between piped chunks, before the request starts, the stream is locked. 11.8.6 threw on
end()there; #6's payload-lessend()let it through, and the server got a partial body.Fix: the locked
end()also throws while any piped source hasreadableEnded === false.stream.pipeline()callsend()from the source'sendevent, whenreadableEndedis alreadytrue, so that case still works. Legacy streams withoutreadableEndedare treated as ended.source.write('first chunk'); destination.end(); source.end('second chunk')end()throws, server receives 23 bytesend()returns, server receives 11 bytesend()throws, server receives 23 bytesTest:
throws when ending a stream while a source is still piped.3b.
end()while abodystream, form-data or legacy stream is still being sent (fifth review)The piped-source rule above only looked at modern
Readablesources already piped in. A streambodyoption is piped once the request starts, and legacy streams (including form-data'sCombinedStream) have noreadableEnded, so a bareend()was accepted while they were still sending:got.stream.put({body: passThrough}); s.end()body: fs.createReadStream(...)ERR_STREAM_WRITE_AFTER_END, process hangsbody: formDataStreampiped in,end()after first dataFix: sources (piped in, or a stream
bodyoption from the constructor) count as active until they emitendorclose. The listeners are registered withprependListener, sostream.pipeline()can still end the stream from its ownendlistener.Tests:
throws when ending a stream while its body stream is still streaming,... its form-data body is still streaming,... a legacy stream is still piped(each also checks the full body arrives). With the previous rule the first and third fail and the form-data test hangs.4. Locked
end()with an empty chunkAllowing
end('')on a locked stream was wrong: once the request has started, Node still sends a zero-length write, and the request failed asynchronously withERR_STREAM_WRITE_AFTER_END. Empty chunks count as a payload again, soend('')throws synchronously as in 11.8.6.Test:
throws when ending a locked stream with an empty chunk.Verification
Each new test fails with its change reverted (checked one at a time on Node.js 24.20.0):
setImmediateinstead ofclose+ timeoutERR_SOCKET_CLOSEDnot deferredneither error nor closetest never resolvesthis[kRequest]!.emit('upload-complete')Uncaught exceptionend()test failsOPENSSL_BINpointing at OpenSSL 3), Node.js 24.20.0, 26.8.2 and 24.16.0: 554 tests, 551 pass, 0 fail, 3 skipped on each.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
Medium Risk
Changes core upload finalization, retry error ordering, and stream write-lock behavior on all requests; regressions could affect retries, redirects, or streaming uploads, though behavior is heavily test-backed.
Overview
Tightens Got’s request Duplex around uploads, retries, and redirects after regressions in the write-lock and
ECANCELEDhandling work.Socket / retry timing:
ClientRequest.end()errors withECANCELED,ERR_STREAM_DESTROYED, orERR_SOCKET_CLOSEDare no longer surfaced immediately when the socket is not closed yet. They are routed through_beforeErroron the request’sclose(or this stream’sclose), with a 1 s fallback so hung sockets still fail. That preserves retries when the realerror(e.g.ECONNRESET) arrives after the end callback— including Node 24+ERR_SOCKET_CLOSEDon connect.Redirects:
upload-completeis emitted on the localrequestthat finished the upload, avoiding an uncaught throw when redirect logic clearsthis[kRequest]while a large upload completes duringbeforeRedirect.Write lock / piped bodies: Active upload sources are tracked in
kPipedSourcesvia_trackPipedSource(piped readables, streambody, unpipe cleanup). While any source is active, lockedend()throws again (including''/ emptyBuffer), blocking truncated uploads andERR_STREAM_WRITE_AFTER_ENDfrom payload-less ends.stream.pipeline()-style ends without a chunk when all sources have ended stay allowed.Clone helper:
trackCloneCompletionkeeps the overriddencompletegetter enumerable and configurable so snapshot clones behave like before.Coverage adds redirect upload, retry/socket, stream lock, and listener-leak tests in
test/retry.ts,test/redirects.ts, andtest/stream.ts.Reviewed by Cursor Bugbot for commit 268df50. Bugbot is set up for automated code reviews on this repo. Configure here.