http: honor Connection: close on non-2xx responses - #36370
Conversation
The origin wrote its early reply and then immediately sock.end()'d. The proxy relays that FIN and end()s the client leg while bun is still writing ~1MB of request body; if bun's on_writable/on_close fires before the reply bytes are processed it observes a shut-down socket and fails with ConnectionClosed (surfaced as ECONNRESET, errno 0). Keep the origin socket open after writing the reply; the client closes after reading Connection: close, which tears down the proxy->origin leg. Disposal now destroys any socket still open.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughChangesHTTP keep-alive handling
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
The response Connection header was only applied when the status code was 2xx. That guard dates from when keep-alive defaulted off and the block only handled "Connection: keep-alive"; once the default flipped to keep-alive on, the guard started meaning "ignore Connection: close on 4xx/5xx", so a 413/500 with Connection: close was pooled. When the server (or a relaying proxy) then closed the socket, a concurrent fetch that picked it from the pool failed with ECONNRESET. Honor "Connection: close" for every status code (RFC 9112 section 9.6); keep the 2xx guard only for the explicit "keep-alive" case. This reverts the previous test-side workaround in proxy-stress-protocol.test.ts; the original sock.end() behavior is realistic and is now handled correctly.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/http/lib.rs`:
- Around line 4907-4918: Update the Connection-header handling around the
existing allow_keepalive assignments to parse every comma-separated token across
repeated Connection fields, rather than comparing each full field value. Trim
and case-insensitively match tokens, make any “close” token terminal so later
“keep-alive” values cannot re-enable pooling, and add coverage for
comma-separated values plus both repeated-field orderings.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 76f974e6-9897-42cc-8da5-b4d7621b4bb8
📒 Files selected for processing (2)
src/http/lib.rstest/js/web/fetch/fetch-keepalive.test.ts
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/web/fetch/fetch-keepalive.test.ts`:
- Line 83: Update the parameterized test declaration for the independent status
cases to use test.concurrent.each instead of test.each, preserving the existing
statuses, test name, and callback behavior.
- Around line 121-126: Update the assertion around the child process result in
the fetch keepalive test so stderr is always validated, including when stdout
contains JSON. Preserve the existing JSON result and exitCode expectations, and
assert that the child process emits no unexpected stderr, consistent with
subprocesses launched through bunEnv.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1d13aa23-6929-4fcb-882e-28cc58607e82
📒 Files selected for processing (1)
test/js/web/fetch/fetch-keepalive.test.ts
There was a problem hiding this comment.
LGTM — both earlier concerns are addressed: the test-side workaround in proxy-stress-protocol.test.ts was reverted in favor of the real runtime fix (net diff to that file is now zero, so the reply-then-close scenario stays covered), and the source comment is trimmed to the durable RFC 9112 §9.6 invariant.
What was reviewed:
Connectionheader handling inhandle_response_metadata:closenow unconditionally clearsallow_keepalive; the 2xx gate remains only on the explicitkeep-aliveopt-in. Strictly narrows pooling — fail-safe direction.- New
test.concurrent.each([200, 400, 413, 500])case follows the existing subprocess pattern in the same file; verified it asserts distinct connections (4) rather than reuse (1). - Confirmed
proxy-stress-protocol.test.tsis no longer in the diff, so the originalsock.end()early-close coverage is intact.
Extended reasoning...
Overview
Two files: src/http/lib.rs moves the Connection: close check out from under the 200..=299 status guard so it applies on any status (RFC 9112 §9.6), while keeping the explicit Connection: keep-alive opt-in gated on 2xx. test/js/web/fetch/fetch-keepalive.test.ts adds a parameterized subprocess test proving a Connection: close response is not pooled for 200/400/413/500. The earlier commit that removed sock.end() from the proxy-stress test fixture has been reverted — that file has zero net diff.
Security risks
None introduced. The change strictly reduces the set of sockets returned to the keep-alive pool (never pools a socket the server said it is closing on a non-2xx). Pooling fewer connections is the safe direction; there is no path here that pools a connection that would previously have been closed.
Level of scrutiny
HTTP client connection pooling is production-critical, but the change is ~14 lines, mechanically restructures an existing conditional, and is behavior-preserving on every path except the one being fixed (non-2xx + Connection: close, which now correctly disables reuse). I traced each of the four (2xx/non-2xx × close/keep-alive) combinations against old vs new code and only the intended one changes. The PR description includes git-blame archaeology explaining why the guard existed (2b45c8d, when keep-alive defaulted off) and stress-test data (91/300 → 0/300 ECONNRESET).
Other factors
Both of my prior inline comments are resolved: (1) my concern about routing around a runtime bug by editing the test fixture was addressed by fixing the runtime and reverting the fixture change; (2) the source comment was trimmed to two lines carrying only the spec citation and asymmetry rationale. CodeRabbit's token-list-parsing note was correctly scoped out as pre-existing behavior. The comment-cop and test.concurrent.each feedback were applied in a47c6c9. No findings from the bug hunter this run.
|
@robobun in another PR, figure out why this test is slow and ifx it: |
|
@robobun most likely that test should be refactored to explicitly set send/recv buffer sizes so it can avoid the 64 MB network activity saturating network in --parallel |
|
Opened #36376 for the |
|
CI on build #85284: all 13 failing files are tagged flaky by the scraper (parallel-batch timeouts, timing assertions under ASAN, Windows install). None touch Diff is green for what it changes. |
…p-alive (#37530) `fetch()` returns the connection that carried an HTTP/1.0 response to the keep-alive pool as long as the response had a Content-Length, whether or not the server said `Connection: keep-alive`. ### Repro Raw server that answers every request with `HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok` and leaves the socket open, counting accepted connections: ```js import net from "node:net"; let connections = 0; const server = net.createServer(sock => { connections++; sock.on("data", () => sock.write("HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok")); }).listen(0, "127.0.0.1", async () => { const url = `http://127.0.0.1:${server.address().port}/`; for (let i = 0; i < 4; i++) await (await fetch(url)).text(); console.log(connections); // bun 1.4.0 and main: 1, node (http.Agent keepAlive) and undici: 4 process.exit(); }); ``` Adding `Connection: keep-alive` to the response is what should make this print 1 (and does, in bun and node alike). Real HTTP/1.0 servers (`python -m http.server`, and most other respond-and-close servers) close the socket right after the response, so in practice the pooled socket races the server's FIN: the next fetch to that origin is written onto it and only succeeds because `on_close` retries idempotent requests on reused connections. A POST loses that race with ECONNRESET. Since #37451 the hop of a followed redirect takes the 3xx's connection back out of the pool synchronously, which makes the race a certainty for an HTTP/1.0 3xx (#37522 separately gates that release on the hop being idempotent; the HTTP/1.0 default underneath is still wrong and this fix is independent of it). ### Cause `handle_response_metadata` never looks at `response.minor_version`. `allow_keepalive` starts out `true` and is only cleared by `Connection: close`, missing framing, or an upgrade, so an HTTP/1.0 response with a Content-Length falls through to HTTP/1.1's persistent default. ### Fix The `Connection` header arm now also records whether any field line carried a `keep-alive` token (`close` still clears `allow_keepalive` on the spot, so it stays sticky across lines and wins over `keep-alive`). After the header loop, an HTTP/1.0 response without such a token clears `allow_keepalive`. Why this is the right rule: RFC 9112 section 9.3 makes an HTTP/1.0 response non-persistent unless it carries `Connection: keep-alive` (that header is how HTTP/1.0 keep-alive was negotiated in the first place, and bun already sends `Connection: keep-alive` on its requests, so servers that support it will answer with it). Node's HTTP parser (`shouldKeepAlive`), undici and curl all implement the same rule; the numbers in the repro are node's. Only `Connection` is consulted: a `Keep-Alive:` parameters header on its own does not count, same as in node and curl. The check is placed after the early return for a proxy's 2xx reply to CONNECT on purpose. tinyproxy, Apache mod_proxy_connect and older Squid answer CONNECT with `HTTP/1.0 200 Connection established`; that status line describes the hop to the proxy, and since `state` is not reset between the CONNECT reply and the tunneled response, clearing `allow_keepalive` there would stop every tunnel through such a proxy from being pooled. The origin's response inside the tunnel goes through the same function later and is judged on its own version, exactly like a direct connection. The h2/h3 sessions feed a synthetic `minor_version: 0` through this function too; they already overwrite `allow_keepalive` right afterwards because HTTP/1.x persistence rules do not apply to them, and the comment there now says so. ### Tests `test/js/web/fetch/fetch-keepalive.test.ts`: - The `Connection: close` table is generalised to status line + header lines + expected connection count and gains HTTP/1.0 rows: no Connection header (200 and 404), `Keep-Alive:` header alone, `Connection: keep-alive` (both spellings, still pooled), and `keep-alive` combined with `close` on one line or across two lines in either order (still not pooled). HTTP/1.1 without a Connection header is pinned as still pooled. - A CONNECT-proxy table: `HTTP/1.0 200` CONNECT reply with an HTTP/1.1 origin still pools the tunnel, so does an HTTP/1.0 origin that says keep-alive, and an HTTP/1.0 origin without it makes each fetch open a new tunnel. - The redirect table gains an HTTP/1.0 302 (hop and later fetches dial again, like the `Connection: close` row) and an HTTP/1.0 302 with `Connection: keep-alive` (one connection throughout). Without the `src/http/lib.rs` change, main fails exactly the five rows that describe the bug (three direct, the HTTP/1.0-origin tunnel row and the HTTP/1.0 302 row, each with one connection instead of 4 / 3 / 5); with it all 36 tests in the file pass. `fetch-redirect`, `fetch-connection-header`, `fetch-url-after-redirect`, `fetch-proxy-connect-tunnel-split-envelope`, `fetch-http2-client`, `fetch-http3-client`, `client-fetch`, `proxy.test.ts`, `proxy-stress-protocol` (which has an HTTP/1.0-origin-through-tunnel group), `proxy-stress-matrix` and `proxy-stress-concurrent` pass on the debug build as well. Note: #37522 rewrites the redirect section of the same test file; whichever of the two lands second needs its two HTTP/1.0 redirect rows moved over. The `lib.rs` changes do not overlap. This is the half of #35545 that #36370 did not pick up.
Fixes the flake in
test/js/bun/http/proxy-stress-protocol.test.tsearly reply during upload, seen on debian 13 x64-asan:Cause
The response
Connectionheader was only applied when the status code was 2xx:That guard dates from 2b45c8d when keep-alive defaulted off and this block only handled
Connection: keep-alive(opt in on 2xx success). Once the default flipped to keep-alive on, the same guard started meaning "ignoreConnection: closeon 4xx/5xx", so a 413/500 carryingConnection: closeleftallow_keepalivetrue and the socket was released into the pool.In the flaky test: origin replies
500 / Connection: closeafter 1KB of a 1MB upload and ends; the proxy relays the reply and the FIN. bun parses the reply, pools the socket (because 500 skipped theclosecheck, and the 1MB body already fit in the loopback send buffer sorequest_side_drainedwas true), and a concurrent early-reply test sharing the samesharedHttpsProxypicks the about-to-die socket from the pool. Absolute-form proxy connections are keyed on the proxy URL alone, so the threehttps-proxy → http-originvariants all draw from the same pool entry.With
BUN_DEBUG_fetch=1under concurrency:Fix
Honor
Connection: closefor every status code (RFC 9112 section 9.6: "A client that receives a 'close' connection option MUST cease sending requests on that connection"). The 2xx guard is kept only for the explicitConnection: keep-alivecase.The earlier test-side workaround (keeping the origin socket open) is reverted; the original
sock.end()is realistic server behavior and is now handled correctly.Verification
Standalone stress repro (https-proxy to http-origin early-reply,
keepalive: true, 24 concurrent x 300 iterations, debug+ASAN):sock.end()(as in the test)keepalive: falsebun bd test test/js/web/fetch/fetch-keepalive.test.ts: newa {200,400,413,500} response with Connection: close is not pooledcases fail before (connections: 1for 4xx/5xx) and pass after (connections: 4)bun bd test test/js/bun/http/proxy-stress-protocol.test.ts: 102 pass, 20/20 iterations under ASANproxy-stress-*.test.tsfiles pass