Skip to content

fetch: honour Connection: close on non-2xx responses and HTTP/1.0 defaults in the keep-alive pool - #35545

Closed
robobun wants to merge 5 commits into
mainfrom
farm/69579668/fetch-connection-close-non-2xx
Closed

fetch: honour Connection: close on non-2xx responses and HTTP/1.0 defaults in the keep-alive pool#35545
robobun wants to merge 5 commits into
mainfrom
farm/69579668/fetch-connection-close-non-2xx

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

fetch() was returning a connection to the keep-alive pool after a response that said Connection: close, as long as the status code was not 2xx. It was also pooling HTTP/1.0 responses that carried no Connection: keep-alive.

RFC 9112 §9.6: once a close connection option is received the client MUST NOT send further requests on that connection. RFC 9112 §9.3: an HTTP/1.0 response is non-persistent by default. Node/undici open a fresh connection in every one of these cases.

Repro

import net from "node:net";
for (const [name, line, hdr] of [
  ["1.1 200 close", "HTTP/1.1 200 OK", "Connection: close\r\n"],
  ["1.1 404 close", "HTTP/1.1 404 Not Found", "Connection: close\r\n"],
  ["1.1 503 close", "HTTP/1.1 503 Unavailable", "Connection: close\r\n"],
  ["1.0 200", "HTTP/1.0 200 OK", ""],
]) {
  let conns = 0;
  const srv = net.createServer(s => { ++conns; let b = ""; s.on("data", d => { b += d;
    while (b.includes("\r\n\r\n")) { b = b.slice(b.indexOf("\r\n\r\n") + 4);
      s.write(line + "\r\n" + hdr + "content-length: 2\r\n\r\nhi"); } }); });
  await new Promise(r => srv.listen(0, "127.0.0.1", r));
  for (let i = 0; i < 3; i++) await (await fetch("http://127.0.0.1:" + srv.address().port + "/")).arrayBuffer();
  srv.close(); console.log(name, "connections:", conns);
}

Before (bun 1.4.0-canary): 200 close opens 3 connections, the other three rows all print connections: 1 (the closed/non-persistent socket was reused). Node prints 3 for every row. Origins that actually close after an error response (nginx 4xx/5xx, LB drains, S3) turn the reuse into sporadic ECONNRESET on the follow-up request.

Cause

handle_response_metadata guarded the Connection header with response.status_code >= 200 && response.status_code <= 299, so close on a 3xx/4xx/5xx was ignored and allow_keepalive stayed at its default of true. There was no minor_version check at all, so HTTP/1.0 responses also fell through to the HTTP/1.1 persistent default.

Fix

  • Drop the 2xx guard on the Connection header branch; close / keep-alive now apply regardless of status code.
  • Before the header loop, set allow_keepalive = false when response.minor_version == 0; an explicit Connection: keep-alive in the loop can turn it back on. h2/h3 pass through this function with a synthetic minor_version: 0 but already overwrite allow_keepalive = true afterwards, so multiplexed pooling is unaffected.

Verification

bun bd test test/js/web/fetch/fetch-keepalive.test.ts passes (6/6). The new test fails on an unpatched build with connections: 1 for the 404/503/302+close and HTTP/1.0 rows and passes with the fix. fetch-redirect.test.ts, fetch-connection-header.test.ts and fetch-http2-client.test.ts are unchanged.


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/fetch-keepalive.test.ts
bun test v1.4.0 (ff67ffe6f)

test/js/web/fetch/fetch-keepalive.test.ts:
(pass) keepalive [71.01ms]
(pass) fetch does not reuse a pooled TLS connection for a request with a different Host header [210.52ms]
(pass) PUT with a ReadableStream body is not retried on keep-alive disconnect [594.71ms]
(pass) an early response to a streaming POST closes the socket instead of pooling it mid-chunked-body [1975.45ms]
309 |     stderr: "pipe",
310 |   });
311 | 
312 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
313 |   const result = stdout.startsWith("[") ? JSON.parse(stdout.trim()) : { stdout, stderr };
314 |   expect({ result, exitCode }).toEqual({
                                     ^
error: expect(received).toEqual(expected)

  {
    "exitCode": 0,
    "result": [
      {
        "connections": 3,
        "expected": 3,
        "per": "1,2,3",
        "row": "1.1 200 close",
      },
      {
-       "connections": 3,
+       "connection
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (824db453e)

test/js/web/fetch/fetch-keepalive.test.ts:
(pass) keepalive [58.70ms]
(pass) fetch does not reuse a pooled TLS connection for a request with a different Host header [13.79ms]
(pass) PUT with a ReadableStream body is not retried on keep-alive disconnect [114.77ms]
(pass) an early response to a streaming POST closes the socket instead of pooling it mid-chunked-body [31.56ms]
(pass) Connection: close on a non-2xx response and HTTP/1.0 defaults are not pooled [46.86ms]
(pass) a proxy that answers CONNECT with HTTP/1.0 200 still allows the tunnel to be pooled [44.17ms]
(pass) a completed streaming POST keeps its connection in the keep-alive pool [28.57ms]

 7 pass
 0 fail
 10 expect() calls
Ran 7 tests across 1 file. [1.54s]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/fetch-keepalive.test.ts
bun test v1.4.0 (ff67ffe6f)

test/js/web/fetch/fetch-keepalive.test.ts:
(pass) keepalive [54.04ms]
(pass) fetch does not reuse a pooled TLS connection for a request with a different Host header [154.06ms]
(pass) PUT with a ReadableStream body is not retried on keep-alive disconnect [412.57ms]
(pass) an early response to a streaming POST closes the socket instead of pooling it mid-chunked-body [1282.89ms]
(pass) Connection: close on a non-2xx response and HTTP/1.0 defaults are not pooled [1799.51ms]
(pass) a proxy that answers CONNECT with HTTP/1.0 200 still allows the tunnel to be pooled [1599.57ms]
(pass) a completed streaming POST keeps its connection in the keep-alive pool [1326.50ms]

 7 pass
 0 fail
 10 expect() calls
Ran 7 tests across 1 file. [9.00s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1105ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/138] gen bindgenv2
[2/138] gen ErrorCode+*.h
[3/138] gen cpp.rs (cppbind)
[4/138] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[5/138] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[6/138] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fiel
... (truncated)
diff hotspot
src/http/lib.rs                           |  30 +++---
 test/js/web/fetch/fetch-keepalive.test.ts | 152 ++++++++++++++++++++++++++++++
 2 files changed, 169 insertions(+), 13 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                       reads  edits  tests
src/http/lib.rs                                8      6      0
test/js/web/fetch/fetch-keepalive.test.ts      2      3      0

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6cb62923-18f9-40f6-9b3b-4b9d74ab0ac2

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 000f112.

📒 Files selected for processing (2)
  • src/http/lib.rs
  • test/js/web/fetch/fetch-keepalive.test.ts

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. ConnectionClosed: The socket connection was closed unexpectedly. For more information, pass verbose: true in the second argument to fetch() #9881 - Sporadic ConnectionClosed / socket closed unexpectedly errors are the exact symptom of reusing a connection the server marked for closure via Connection: close on non-2xx responses
  2. bun fetch to wrangler dev server crashes server and fails with "socket connection was closed unexpectedly" #12730 - Wrangler dev server fetch failures with "socket connection was closed unexpectedly" likely caused by incorrectly pooling connections from HTTP/1.0 or Connection: close responses
  3. HTTP keep-alive bug causes wildly bad networking performance with fetch in bun vs nodejs when switching between similar endpoints #9034 - Multi-second stalls when switching between fetch endpoints consistent with reusing connections closed by HTTP/1.0 servers, then waiting for TCP timeout
  4. next start under Bun returns 200 empty bodies for server-side fetch to local PostgREST, while Node returns real data #29515 - Empty response bodies from PostgREST under Bun likely caused by reusing connections from HTTP/1.0 or Connection: close responses that the server already closed

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #9881
Fixes #12730
Fixes #9034
Fixes #29515

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Looked at the four suggested issues; none are a clear enough match to auto-close:

Leaving them unlinked.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:13 PM PT - Jul 25th, 2026

@robobun, your commit 000f112 is building: #81220

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix fetch handling of Connection header close tokens #31464 - Both PRs modify handle_response_metadata in src/http/lib.rs to fix fetch() keep-alive connection pooling by changing how the Connection header sets allow_keepalive. Fix fetch handling of Connection header close tokens #31464 focuses on comma-separated token parsing (e.g. Connection: close, keep-alive), while this PR removes the status-code 200-299 guard and adds HTTP/1.0 default handling. They change the same code block for the same purpose.

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #31464. That PR parses Connection as a comma-separated token list (so Connection: close, keep-alive is recognised) but keeps the status_code >= 200 && status_code <= 299 guard in place, so a 404 with Connection: close is still pooled there. This PR removes that guard and adds the HTTP/1.0 non-persistent default. The two are complementary; whichever lands second will need a small rebase of the same block.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline finding, I verified the PR's h2/h3 claim: apply_multiplexed_headers at src/http/lib.rs:386 does overwrite allow_keepalive = true after handle_response_metadata, so multiplexed pooling is unaffected by the new minor_version == 0 default.

Extended reasoning...

Confirmed the h2/h3 path builds a synthetic response with minor_version: 0 (line 373) but unconditionally sets allow_keepalive = true at line 386 after the header loop, so the new HTTP/1.0 default does not regress h2/h3 connection reuse. The CONNECT-tunnel regression in the inline comment is the one path where the flag sticks.

Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated
robobun added 3 commits July 25, 2026 04:55
…aults

The HTTP client only applied the response's Connection header when the
status code was 2xx, so a 3xx/4xx/5xx response carrying Connection: close
was returned to the keep-alive pool and the next request to that origin
was written onto a socket the server declared closed. Origins commonly
send close on error responses and then actually close, so the reused
connection races the FIN.

Separately, HTTP/1.0 responses were treated as persistent by default.
RFC 9112 9.3: an HTTP/1.0 response is non-persistent unless it carries
an explicit Connection: keep-alive.

Drop the status-code guard on the Connection header, and set
allow_keepalive = false for HTTP/1.0 responses before the header loop
so an explicit keep-alive can turn it back on.
handle_response_metadata runs once for the proxy's CONNECT reply and
again for the origin response inside the tunnel, with no state reset in
between. An HTTP/1.0 200 Connection Established from the proxy (Squid,
tinyproxy, Apache mod_proxy_connect) would latch allow_keepalive=false
and the origin's implicit HTTP/1.1 keep-alive could not clear it, so
the tunnel was closed instead of pooled after every request.

Gate the default on !(proxy_tunneling && proxy_tunnel.is_none()),
mirroring the CONNECT exemption on the Content-Length and
Transfer-Encoding arms. Add a regression test that counts CONNECTs
through a raw HTTP/1.0 proxy.
@robobun
robobun force-pushed the farm/69579668/fetch-connection-close-non-2xx branch from 9a743bd to ff67ffe Compare July 25, 2026 05:01

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit: re-checked the CONNECT gate now in place (matches the Content-Length/Transfer-Encoding exemption predicate) and the new proxy regression test covers it. Also verified h2/h3 pooling is unaffected — apply_multiplexed_headers synthesizes minor_version: 0 but overwrites allow_keepalive = true immediately after handle_response_metadata returns (lib.rs:386). The now-unguarded Connection header arm doesn't regress CONNECT-200 behavior since 200 was already inside the old 2xx gate.

Extended reasoning...

Re-review after the CONNECT-proxy fix landed. The src/ change is small and spec-correct; the remaining inline finding is a cosmetic comment-ordering nit in the test file. Deferring rather than approving because this is core fetch() connection-pool logic with broad blast radius, and CI (#80237) is still building.

Comment thread test/js/web/fetch/fetch-keepalive.test.ts
Keeps the 'Negative contract for the gate above' comment adjacent to
the test it refers to.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the CONNECT gate, comment cleanup, and test ordering from earlier rounds are all addressed.

What was reviewed:

  • Traced the new minor_version == 0 default through the CONNECT-reply path (now correctly exempted via the same predicate as the Content-Length/Transfer-Encoding arms) and the h2/h3 path (apply_multiplexed_headers overwrites allow_keepalive = true at lib.rs:386, so the synthetic minor_version: 0 there is harmless).
  • Checked that dropping the 2xx guard on the Connection header doesn't regress the CONNECT flow: 200 CONNECT was already inside the old range, and non-200 CONNECT sets disable_keepalive = true unconditionally.
  • New tests cover the matrix (close on 200/404/503/302, HTTP/1.0 with and without explicit keep-alive, HTTP/1.1 implicit keep-alive as negative control) plus the HTTP/1.0-CONNECT-proxy regression guard; both follow the file's subprocess pattern and assert exact per-connection ids.
Extended reasoning...

Overview

Two-file change: src/http/lib.rs (~17 net lines in handle_response_metadata) and two new tests in test/js/web/fetch/fetch-keepalive.test.ts (~150 lines). The production change (1) removes the status_code >= 200 && <= 299 guard around the Connection header arm so close/keep-alive apply on any status, and (2) adds an HTTP/1.0 non-persistent default (allow_keepalive = false when minor_version == 0), gated to skip the proxy CONNECT reply.

Security risks

None introduced. The change makes connection reuse more conservative, closing sockets that were previously (incorrectly) pooled. There is no new parsing, no new user-controlled input path, and the HTTP/1.0 default fails closed. The one potential regression surface — a CONNECT reply's HTTP version poisoning tunnel pooling — was caught in the first review round and is now both gated and covered by a dedicated regression test.

Level of scrutiny

Medium. This is hot-path HTTP client code, but the change is small, mechanical, and directly implements RFC 9112 §9.3/§9.6. I verified the three paths that could interact with the new logic:

  • h2/h3 (apply_multiplexed_headers, lib.rs:367-386): builds a synthetic response with minor_version: 0, but unconditionally sets allow_keepalive = true immediately after handle_response_metadata returns, so the new default is a no-op there.
  • CONNECT reply: the !(proxy_tunneling && proxy_tunnel.is_none()) gate mirrors the existing Content-Length / Transfer-Encoding exemptions at lib.rs:4785-4790 and 4848-4853. For the un-gated Connection-header arm, a 200 CONNECT with Connection: close behaved identically before (200 was inside the old guard), and a non-200 CONNECT already forces disable_keepalive = true at lib.rs:4979.
  • Redirects: state.reset() on the redirect path restores allow_keepalive's default of true for the next hop, so a Connection: close on a 302 correctly closes only that hop's socket.

Other factors

All three prior review threads (CONNECT gate, two comment-cop blocks, test-pair ordering) are resolved and reflected in the current diff. The evidence block shows the new test failing on the unpatched debug build and passing with the fix; the release-without-fix pass is expected (release canary predates the test). Test quality is good: subprocess isolation for pool state, exact per-request connection-id assertions, negative-control rows (1.0 200 keep-alive → 1 conn, 1.1 404 implicit → 1 conn) that would catch over-eager closing, and the CONNECT-proxy test locks in the exemption. This is complementary to (not a duplicate of) #31464, which the author already noted.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is ready. CI build #81220 finished with a single unrelated failure: test/regression/issue/20144/20144.test.ts tagged [flaky]. fetch-keepalive.test.ts passed on every lane.

The earlier binary-size failure on #80268 was baseline drift (this PR's src/ delta is +4 net lines in handle_response_metadata) and has cleared now that the canary baseline updated.

Locally the new test fails on main's src/http/lib.rs (5 of 8 rows reuse the closed socket) and passes with the fix; the CONNECT-proxy and 303-redirect regression tests pass on both.

A completed chunked ReadableStream body sets request_stage=Done, so the
do_redirect pool/close decision applies. With Connection: close ignored on
3xx (the bug this PR fixes) the closing socket was pooled and immediately
reused for the follow-up GET, which either hangs (origin lingers per
RFC 9112 9.6) or gets delivered twice (origin closes, GET hits FIN,
idempotent retry fires).
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

This also fixes a downstream symptom: a fetch() with a ReadableStream request body that gets a 303 (or 301/302) carrying Connection: close was pipelining the redirected GET onto the closing connection. A completed chunked stream body is the only body shape that reaches request_stage == Done at do_redirect time, so with allow_keepalive left at its default the socket was pooled and immediately picked up by start() for the follow-up hop.

Two failure modes depending on what the origin does after close:

  • origin closes right after the 303: the pipelined GET hits FIN, and because the follow-up has an empty Bytes body the idempotent-retry path resends it on a fresh connection, so the origin receives the GET twice
  • origin lingers (drains then sits, per RFC 9112 9.6 "SHOULD NOT process any further requests"): fetch() hangs waiting on a socket nobody will read from

node/undici open a fresh connection in both cases.

Added a test in 000f112 that reproduces the linger variant: without this PR's src change conn0 receives both POST /first and the pipelined GET /second and the fetch times out; with it conn0 sees only the POST, the GET goes out on a new connection, and the redirect resolves.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the CONNECT-proxy regression I flagged earlier is fixed and now guarded by a test, and the test-ordering nit is resolved.

What was reviewed:

  • Verified the minor_version == 0 default is overwritten by apply_multiplexed_headers (line 386) so h2/h3 pooling is unaffected, and that the CONNECT exemption predicate matches the existing Content-Length/Transfer-Encoding gates.
  • Checked that dropping the 2xx guard on the Connection header doesn't change CONNECT-reply behaviour (200 was already inside the old guard) and that a non-200 CONNECT hits is_proxy_connect_failure regardless.
  • The 8-row matrix test covers both directions (rows that must open 3 connections and rows that must reuse 1), plus the CONNECT-proxy and 303-redirect-after-streaming-POST regression tests.
Extended reasoning...

Overview

The PR changes handle_response_metadata in src/http/lib.rs (net +4 lines): it removes the status_code >= 200 && status_code <= 299 guard around the Connection header handling so close/keep-alive apply on any status, and adds an HTTP/1.0 non-persistent default (allow_keepalive = false when minor_version == 0) gated on !(proxy_tunneling && proxy_tunnel.is_none()) to skip the CONNECT reply. Three new subprocess tests in fetch-keepalive.test.ts cover an 8-row status×version matrix, the CONNECT-proxy exemption, and a 303-with-close redirect after a streaming POST.

Security risks

None introduced. The change is conservative in direction: it causes more connections to be closed rather than pooled, so the failure mode is extra TCP/TLS handshakes rather than reuse of a desynchronised or half-closed socket. The 303 test in fact demonstrates a request-smuggling-adjacent symptom (follow-up GET pipelined into a closing connection) that this fix eliminates.

Level of scrutiny

Moderate. src/http/lib.rs is production-critical, but the change is a small, spec-driven correction (RFC 9112 §9.3/§9.6) to a single header-processing branch. I traced the three interaction points that could regress: h2/h3 (apply_multiplexed_headers unconditionally rewrites allow_keepalive = true after calling this function, so the synthetic minor_version: 0 is harmless), CONNECT tunneling (the added exemption uses the exact predicate already used by the Content-Length arm and the start_proxy_handshake dispatch), and the Connection header on CONNECT replies (200 was already inside the old 2xx guard, so no behaviour change there; non-200 CONNECT sets is_proxy_connect_failure and never pools anyway).

Other factors

All three of my earlier review comments were addressed with follow-up commits and regression tests. The new tests follow repo conventions (subprocess isolation with a stated reason, port: 0, concurrent stdout/stderr/exited drain, combined-object assertions, bunEnv spread). The matrix test includes negative-contract rows (1.0 200 keep-alive → 1 connection, 1.1 404 implicit keep-alive → 1 connection) so over-eager closing would also fail. The CI binary-size failure is baseline drift unrelated to this +4-line src change. The evidence block shows the test fails on main and passes with the fix.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Closing this since #36370 (http: honor Connection: close on non-2xx responses) merged and covers the same ground. Thank you @robobun for the PR — if there's a piece of this that #36370 didn't pick up, please say so and we'll take another look.

(This comment was written by Claude, on behalf of the Bun team.)

Jarred-Sumner pushed a commit that referenced this pull request Aug 11, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants