http: don't pool a connection when bytes trail a bodyless response - #37438
Conversation
When a response ends at its header block (204/304, Content-Length: 0, any response to HEAD, or a followed redirect), handle_on_data_headers discarded whatever else was in the packet and still released the socket to the keep-alive pool. Bytes past the end of a message we never pipelined a request for mean the connection's framing can't be trusted, so clear allow_keepalive in that case, the same way handle_response_body already does for a Content-Length overshoot. The response itself is still delivered; only the connection is closed instead of reused. The flag is cleared before the redirect branch so do_redirect's pooling decision sees it too.
|
Updated 3:26 AM PT - Aug 11th, 2026
✅ @robobun, your commit 3de4401981eae448f76834b41cc6d65b7879deb0 passed in 🧪 To try this PR locally: bunx bun-pr 37438That installs a local version of the PR into your bun-37438 --bun |
|
Status: reproduced on bun 1.4.0 with the raw |
WalkthroughHeader-terminated responses with unread trailing bytes now disable connection reuse. Fetch regression tests cover bodyless responses, clean keep-alive reuse, connection replacement, and redirects. ChangesHTTP response reuse
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
That sibling already has its own PR, so this one stays scoped to the chunked terminator. Keep the shared on_bytes_past_response_end helper (now also skipping the log once keep-alive is already off) and trim the comments to one line each.
|
Heads up: #37436 handles the chunked sibling of this (bytes after the terminating chunk) and moves the existing Content-Length overshoot check into a small |
|
One more input for the Finished-branch check, from review on #37436: pretend_304 (force_last_modified, set by bun install under BUN_FEATURE_FLAG_LAST_MODIFIED_PRETEND_304) rewrites a real 200 into a 304 and forces content_length to 0, so handle_response_metadata returns Finished while the server's genuine body bytes are sitting in to_read. The new check then closes that connection. That is the safe outcome (before, the socket was pooled with the rest of the body still undrained), so no behaviour change seems needed, but the comment and any debug log on that branch should not describe those bytes as a server-side framing violation. |
There was a problem hiding this comment.
LGTM. The three-line guard mirrors the existing Content-Length overshoot rule at lib.rs:4539, and both earlier review points (chunked sibling → now scoped to #37436 in the description; long code comment → removed in 3de4401) are resolved.
What was reviewed:
- Confirmed
to_readat theFinishedbranch is the post-header-block remainder (re-sliced at lib.rs:3722), so the emptiness check is the right predicate. - Checked the
pretend_304/force_last_modifiedinteraction — a rewritten 200→304 with real body bytes now closes instead of pooling an undrained socket, which is strictly safer. - Test is hermetic (local
netserver on 127.0.0.1, port 0, sockets destroyed infinally) and asserts the full 10-row matrix including that clean variants still reuse (newConnections: 0).
Extended reasoning...
Overview
Three-line addition to handle_on_data_headers in src/http/lib.rs: when handle_response_metadata returns ShouldContinue::Finished (HEAD, 204/304, Content-Length: 0, or a followed redirect) and to_read — the slice past the parsed header block — is non-empty, clear allow_keepalive so the socket is closed rather than returned to the keep-alive pool. Placed before the is_redirect_pending check so do_redirect sees the same flag. Accompanied by a ~130-line test in fetch.test.ts driving 204/304/CL:0/HEAD/303 through a raw net server, each with and without trailing bytes, asserting exact new-connection counts.
Security risks
None introduced; this is a security-positive change. It closes a response-smuggling / framing-desync vector where a misbehaving server's trailing bytes on a bodyless response would be delivered as the reply to the next request that draws the pooled socket. The only cost is one extra connection to servers that mis-frame, which matches Node's behavior and RFC 9112 §6.3 guidance.
Level of scrutiny
Low-to-medium. The production change is three lines that copy the exact pattern already used four other places in the same file (self.state.flags.allow_keepalive = false), most directly the Content-Length overshoot check at lib.rs:4539-4543. to_read is provably the post-header remainder (re-sliced at lib.rs:3722 after each picohttp::Response::parse_parts). The test is the bulk of the diff and is well-constructed: local server, port 0, explicit 127.0.0.1 binding, cleanup in finally, and a full-table toEqual assertion that verifies both the fix (trailing-bytes rows → 1 new connection) and non-regression (clean rows → 0 new connections).
Other factors
All prior review threads are resolved: my earlier nit about the chunked-terminator sibling was answered (handled in #37436; description now cites it and drops the completeness claim), and the comment-cop flag on the explanatory paragraph was addressed by removing it in 3de4401. The robobun note about pretend_304 is informational — that path now closes instead of pooling a socket with undrained body bytes, which is the safe direction, and with the comment removed there is no misleading wording to adjust. No outstanding reviewer comments.
Repro
Raw server that appends a second, unrequested response to a bodyless one:
Connections column before (bun 1.4.0):
1,1,1,1,1,1, every/legitis sent on the connection that just misbehaved. After:1,2,2,3,3,4. Same thing for a followed redirect: a streamed POST answered with303+Content-Length: 0+ trailing bytes had itsGEThop sent on the same connection.Cause
handle_on_data_headers(src/http/lib.rs): whenhandle_response_metadatareturnsShouldContinue::Finished(HEAD response, 204/304,Content-Length: 0, or a redirect being followed) the remaining bytes of the packet into_readwere dropped on the floor andprogress_update/do_redirectreleased the socket to the keep-alive pool as usual.The rest of this class: a body longer than its
Content-Lengthalready clearsallow_keepaliveinhandle_response_body, data arriving later on an idle pooled socket already gets the socket evicted inHTTPContext::on_data, and bytes after the terminating chunk of a chunked body are fixed in #37436. This PR covers the header-block-terminated responses. #37436 introduces anon_bytes_past_response_endhelper for the Content-Length and chunked sites; whichever of the two PRs lands second should route this check through it too. The two PRs apply independently in either order (checked withgit apply --checkof #37436 on top of this branch).Fix
In the
Finishedbranch, clearstate.flags.allow_keepalivewhento_readis not empty, before the redirect check sodo_redirect(which goes throughis_keep_alive_possibleas well) makes the same decision. The response is still delivered exactly as before; the only change is that the connection is closed instead of pooled.Why closing is the right call: RFC 9112 section 6.3 lets a client discard data left over after a complete response but says it must not be processed as a separate response. We only ever see the part of that data that happened to share a packet with the header block; whatever else the server is going to send would be parsed as the reply to the next request that picks this socket out of the pool. The connection has no trustworthy message boundary any more, which is the same reasoning as the existing Content-Length overshoot rule. Node destroys the socket in this situation too (the leftover bytes fail to parse as a response or are rejected as a double response). The cost is only paid by servers that are mis-framing, and for them it is one extra connection.
HTTPContext::on_datatolerates a bare0\r\n\r\narriving later on an idle pooled socket (a 2022 drive-by for servers that send a chunk terminator after a bodyless chunked response). This change does not special-case that sequence when it arrives in the same packet: such a server still gets its response delivered, it just does not get connection reuse. The idle-socket tolerance itself is left as is.Verification
New test in test/js/web/fetch/fetch.test.ts, placed just ahead of the Content-Length overshoot one (#37436 adds its test right after it). It drives 204, 304,
Content-Length: 0, HEAD and a followed 303 (via a streamed POST, the request shape whose redirect hop currently reuses the pooled socket) through the server above, each once with trailing bytes and once clean, and checks that the trailing-bytes variants cost exactly one new connection for the follow-up request while the clean variants still reuse theirs. Without the fix the five trailing-bytes rows report 0 new connections; with it the whole table matches.Also ran fetch-keepalive, fetch-redirect, fetch-connection-header, content-length, fetch-url-after-redirect and the proxy tests against the debug build; the only failures are the pre-existing ones caused by this container resolving
localhostto::1/ settingNO_PROXY, identical with the release binary.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/fetch/fetch.test.ts