Skip to content

Restore the write lock and retry errors that follow an ECANCELED end callback - #6

Merged
Kikobeats merged 2 commits into
v11from
fix-write-lock-and-ecanceled
Sep 15, 2026
Merged

Kikobeats merged 2 commits into
v11from
fix-write-lock-and-ecanceled

Conversation

@Kikobeats

@Kikobeats Kikobeats commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

Fixes the two regressions an adversarial review found in the v11 branch (11.8.6..50eb9aa) before releasing 11.8.8.

1. Restore the stream write lock, allowing end() without a payload

Regression from #2. Removing the lock (backport of Got 12's bb8eca9) made write() / end(data) on a stream that already has a body/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:

got.stream.post(url + '/upload', {body: 'abc'}).write('GET /smuggled HTTP/1.1\r\nHost: x\r\n\r\n')
// 11.8.6: throws "The payload has been already provided"; server sees POST /upload
// v11:    accepted;                                        server sees POST /upload and GET /smuggled

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 calls end() from its own listener after the lock is back on. Ending without a payload writes nothing, so the locked end() now allows it and still throws when given data.

Tests (test/stream.ts):

  • the two lock tests deleted in Make the full test suite pass on Node.js LTS and latest #2 are restored;
  • 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;
  • the existing piping server request to Got proxies also headers still covers the pipeline() crash.

2. Retry the request error that follows an ECANCELED end callback

Gap in #1, Node.js 24.20+ only. When a request is destroyed without an error (a proxy agent destroying its socket, req.destroy()), Node reports ECANCELED through the end() callback before the request's 'error' event carries the retryable ECONNRESET. Since #1 handles the first error it sees, the request failed without retries.

Event order measured with plain http (upload stalled mid-body):

Node.js socket destroyed without error destroyed with an error
24.16.0 endcb:ok > error:ECONNRESET > close error > close
24.20.0, 26.8.2 endcb:ECANCELED > error:ECONNRESET > close error > endcb:ECANCELED > close

Fix: when end() reports ECANCELED or ERR_STREAM_DESTROYED and 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):

  • ECANCELED then ECONNRESET then close: retried twice with ECONNRESET;
  • ECANCELED then close with no error: rejects with ECANCELED on the first attempt (no hang);
  • request already closed: rejects with ECANCELED (no hang);
  • real socket: a 64 MB PUT whose socket is destroyed 100 ms in retries with ECONNRESET.

Verification

Each new test fails without its fix:

Build Result
v11 source (no lock) on 24.20 both restored lock tests and the injection test fail; pipeline and bare end() tests pass
deferral disabled on 24.20 mocked ECANCELED retry test and the real-socket test fail
deferral disabled on 24.16 only the mocked test fails (the real-socket case is Node 24.20+)

Full suite locally with this branch (macOS, OPENSSL_BIN pointing at OpenSSL 3): Node.js 24.20.0 and 26.8.2 both 543 tests, 540 pass, 0 fail, 3 skipped. 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

High Risk
Changes core HTTP upload/stream behavior and retry error routing; the write lock is security-relevant (request smuggling), and _final error timing affects all retried uploads on newer Node versions.

Overview
Restores two regressions in Got’s stream Request duplex: payload write locking and retry behavior after ECANCELED on upload end.

Write lock: Reintroduces _lockWrite / _unlockWrite so streams with body/json/form, or GET/HEAD without a writable body, reject extra write()/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 so stream.pipeline() on Node 17.3+ still works.

Retry / ECANCELED: On Node 24.20+, a destroyed upload can surface ECANCELED from ClientRequest.end() before a retryable error (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(), mocked ECANCELED→ECONNRESET retry 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.

Kikobeats and others added 2 commits September 15, 2026 18:02
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
@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: 4908a345-ecd3-43be-8ca2-6c85553074a1

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
Kikobeats merged commit e777677 into v11 Sep 15, 2026
4 checks passed
@Kikobeats
Kikobeats deleted the fix-write-lock-and-ecanceled branch September 15, 2026 16:07
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