Skip to content

Fix edge cases from the review of the write lock and ECANCELED handling - #7

Merged
Kikobeats merged 5 commits into
v11from
fix-end-review-findings
Sep 16, 2026
Merged

Kikobeats merged 5 commits into
v11from
fix-end-review-findings

Conversation

@Kikobeats

@Kikobeats Kikobeats commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

Fixes the findings from the second to fifth cold reviews (of #6, then of this PR's commits). v11 already 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:

  • Restore the write lock and retry errors that follow an ECANCELED end callback #6 waited for the request's close after an ECANCELED/ERR_STREAM_DESTROYED end callback, so a custom socket that never emits close (emitClose: false) left the request unsettled.
  • The first attempt at a fix, reporting from setImmediate, lost the retryable error when a socket closes asynchronously.
  • ERR_SOCKET_CLOSED (socket destroyed on connect, Node.js 24.20+) was reported at once and never retried.

Measured with plain http on Node.js 24.20 and 26.8.2:

Case Order
socket destroyed mid-upload endcb:ECANCELED > error:ECONNRESET > close > setImmediate
socket destroyed on connect endcb:ERR_SOCKET_CLOSED > setImmediate > error:ECONNRESET > close
socket destroyed on connect with an error endcb:ERR_SOCKET_CLOSED > error:ECONNRESET > setImmediate > close

So setImmediate is not enough, but the request's error always comes before its close.

Fix: for ECANCELED, ERR_STREAM_DESTROYED and ERR_SOCKET_CLOSED, report the end callback error on the request's close, with a 1 s fallback timer for sockets that never emit it. _beforeError ignores 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 connects
  • retries a request without a body whose socket is destroyed when it connects
  • retries an upload whose socket closes asynchronously after being destroyed (a custom Duplex socket whose _destroy finishes 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 TypeError when the upload finishes during a redirect

Problem: redirect handling deletes this[kRequest] before awaiting beforeRedirect. An upload that finished in that window hit this[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 async beforeRedirect hook takes 500 ms. Without the fix the test file dies with Uncaught exception.

3. Minor, compared with 11.8.6: end() while a source is still piped silently truncated the upload

Problem: between piped chunks, before the request starts, the stream is locked. 11.8.6 threw on end() there; #6's payload-less end() let it through, and the server got a partial body.

Fix: the locked end() also throws while any piped source has readableEnded === false. stream.pipeline() calls end() from the source's end event, when readableEnded is already true, so that case still works. Legacy streams without readableEnded are treated as ended.

Build source.write('first chunk'); destination.end(); source.end('second chunk')
stock 11.8.6 end() throws, server receives 23 bytes
v11 before this PR end() returns, server receives 11 bytes
this PR end() throws, server receives 23 bytes

Test: throws when ending a stream while a source is still piped.

3b. end() while a body stream, form-data or legacy stream is still being sent (fifth review)

The piped-source rule above only looked at modern Readable sources already piped in. A stream body option is piped once the request starts, and legacy streams (including form-data's CombinedStream) have no readableEnded, so a bare end() was accepted while they were still sending:

Case 11.8.6 before this commit
got.stream.put({body: passThrough}); s.end() throws, full body sent no throw, server gets 0 bytes, reported as success
body: fs.createReadStream(...) throws ERR_STREAM_WRITE_AFTER_END, process hangs
body: formData throws request never settles
legacy Stream piped in, end() after first data throws, full body truncated body

Fix: sources (piped in, or a stream body option from the constructor) count as active until they emit end or close. The listeners are registered with prependListener, so stream.pipeline() can still end the stream from its own end listener.

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 chunk

Allowing end('') on a locked stream was wrong: once the request has started, Node still sends a zero-length write, and the request failed asynchronously with ERR_STREAM_WRITE_AFTER_END. Empty chunks count as a payload again, so end('') 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):

Reverted change Result
setImmediate instead of close + timeout destroyed-on-connect and asynchronous-close tests reject without retrying
ERR_SOCKET_CLOSED not deferred destroyed-on-connect test rejects
no fallback timeout neither error nor close test never resolves
empty chunks allowed empty-chunk test fails
this[kRequest]!.emit('upload-complete') redirect test file crashes with Uncaught exception
piped sources ignored piped end() test fails
  • Full suite locally (macOS, OPENSSL_BIN pointing 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.
  • xo on 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 ECANCELED handling work.

Socket / retry timing: ClientRequest.end() errors with ECANCELED, ERR_STREAM_DESTROYED, or ERR_SOCKET_CLOSED are no longer surfaced immediately when the socket is not closed yet. They are routed through _beforeError on the request’s close (or this stream’s close), with a 1 s fallback so hung sockets still fail. That preserves retries when the real error (e.g. ECONNRESET) arrives after the end callback— including Node 24+ ERR_SOCKET_CLOSED on connect.

Redirects: upload-complete is emitted on the local request that finished the upload, avoiding an uncaught throw when redirect logic clears this[kRequest] while a large upload completes during beforeRedirect.

Write lock / piped bodies: Active upload sources are tracked in kPipedSources via _trackPipedSource (piped readables, stream body, unpipe cleanup). While any source is active, locked end() throws again (including '' / empty Buffer), blocking truncated uploads and ERR_STREAM_WRITE_AFTER_END from payload-less ends. stream.pipeline()-style ends without a chunk when all sources have ended stay allowed.

Clone helper: trackCloneCompletion keeps the overridden complete getter 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, and test/stream.ts.

Reviewed by Cursor Bugbot for commit 268df50. Bugbot is set up for automated code reviews on this repo. Configure here.

…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
@coderabbitai

coderabbitai Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 3332c9fd-9e92-443a-b41d-77717c0e24e3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

Kikobeats and others added 3 commits September 15, 2026 19:33
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

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread source/core/index.ts Resolved
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
@Kikobeats
Kikobeats merged commit 4767219 into v11 Sep 16, 2026
4 checks passed
@Kikobeats
Kikobeats deleted the fix-end-review-findings branch September 16, 2026 06:21
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.

1 participant