Bun.serve: stop dispatching pipelined requests after Connection: close (RFC 9112 9.6) - #33005
Conversation
|
Updated 6:12 PM PT - Sep 16th, 2026
⏳ @Jarred-Sumner, your commit 5bf4ec2 is still building in
|
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughUpdates HTTP/1.1 ChangesHTTP/1.1 Connection: close correctness
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 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 `@packages/bun-uws/src/HttpContext.h`:
- Around line 312-315: Shorten the framing-state reset comment in HttpContext so
it fits the repository’s 3-line comment limit. Edit the existing comment near
the writeHead/writeHeader framing flags logic to preserve the same meaning about
stale per-request flags on keep-alive sockets, but condense it to three lines or
fewer without changing behavior.
In `@packages/bun-uws/src/HttpParser.h`:
- Around line 240-243: Trim the newly added block comments in HttpParser.h to 3
lines max, as requested by the review. Update the long explanatory comments near
the connection-token scanner text and the other affected comment blocks so they
keep the same meaning but fit the repository comment-length guideline. Focus on
the comment blocks around the Connection-header/token logic and the other
highlighted parser comments, keeping each concise and within the 3-line limit.
In `@packages/bun-uws/src/HttpResponse.h`:
- Around line 99-113: Condense the expanded explanatory comments in
HttpResponse.h so each comment block stays within the repo’s 3-line limit while
preserving the local invariant; update the comment near the response completion
logic and the matching longer block later in the file, keeping only the
essential behavior around uncorking, deferred close during parsing, and
keepCorked handling, and move any extra rationale out of the code comments.
In `@src/uws_sys/libuwsockets.cpp`:
- Around line 1347-1349: The close-header path in uws_res_end_without_body can
still call writeHeader() after HTTP_END_CALLED, so update the guard around the
Connection: close append to treat HTTP_END_CALLED as headers already closed.
Make the change in uws_res_end_without_body and keep the behavior consistent
with the existing end checks already used later in that function, including the
repeated/late close path handled by the same logic.
In `@test/js/bun/http/serve.test.ts`:
- Around line 2571-2581: The new test comments in the Connection: close request
header block are too long for the repository comment limit. Trim the doc/comment
text around the relevant describe/helper blocks (including the
`describe("Connection: close request header", ...)` section and the related
helpers) so each comment is at most 3 lines, keeping only the durable invariant
needed to understand the test.
- Around line 2753-2754: The cleanup in the test’s finally block is using
nodeServer.close() without waiting for the close event, so the test can finish
before the server is actually shut down. Update the finally path in this test to
await the close operation by wrapping the callback-based close in a promise or
equivalent, so the server is fully closed before the test exits.
🪄 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: f5f8692d-3f2d-42ec-b5e3-2543d0e40a5b
📒 Files selected for processing (6)
packages/bun-uws/src/HttpContext.hpackages/bun-uws/src/HttpParser.hpackages/bun-uws/src/HttpResponse.hpackages/bun-uws/src/HttpResponseData.hsrc/uws_sys/libuwsockets.cpptest/js/bun/http/serve.test.ts
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Thanks, I looked at #28396. I am not adding Two more reasons it is not a clean match:
|
|
Not a duplicate of #28390, though they share the async-close root cause. The differences are covered in my earlier comment above; the short version: #28390's fix shape (uncork then close-check in |
There was a problem hiding this comment.
I didn't find any bugs, but this reworks connection-close handling across the uWS HTTP parser/response lifecycle (the sawConnectionClose latch, the uncork-then-close refactor gated on isParsingHttp, and the wroteConnectionHeader suppression), so it's worth a human pass on the close/parser interaction.
Extended reasoning...
Overview
This PR fixes RFC 9112 §9.6 compliance in Bun.serve by changing how the vendored uWS fork handles a request's Connection: close option. It touches five production source files: HttpParser.h adds hasConnectionToken() and a per-socket sawConnectionClose latch that stops the parse loop and discards pipelined bytes; HttpContext.h switches detection from a length check to the token scanner and resets the new wroteConnectionHeader flag per request; HttpResponse.h extracts an uncorkAndCloseIfNeeded() helper and rewires both chunked and non-chunked end paths through it, gated on !flags.isParsingHttp; HttpResponseData.h adds the wroteConnectionHeader member; and libuwsockets.cpp updates both SSL variants of uws_res_end_without_body. A 14-case test block in serve.test.ts covers the new behavior over a raw socket.
Security risks
The HTTP parser loop and connection-close semantics are request-smuggling-adjacent. The PR description itself notes that an earlier draft (uncork-then-close without the isParsingHttp guard) regressed 26 chunked-size strict-validation cases in request-smuggling.test.ts by closing the socket from inside the synchronous handler before the parser could 400 the malformed body. The current shape defers that close to onData's tail when inside the parser, which the author reports passes the smuggling suite, but the correctness of that gate — and whether discarding pipelined bytes via consumedTotal += length; break; interacts cleanly with the post-padding / fallback-buffer paths in consumePostPadded — is exactly the kind of invariant a human familiar with this parser should sanity-check.
Level of scrutiny
High. This is core HTTP server connection-lifecycle code that every Bun.serve and node:http request flows through. The change is not mechanical: it refactors the uncork/close decision out of two open-coded sites into a helper with a new gating condition, adds state to both the parser (sawConnectionClose) and the response (wroteConnectionHeader), and changes when sockets are shut down. The PR also overlaps with two other open changes (#28390 on the same async-close root cause, and #32945 on HTTP/1.0 keep-alive sharing the same hasConnectionToken helper), so coordination matters.
Other factors
The PR is well-described and the test coverage is thorough (sync/async handlers, HEAD via end_without_body, POST body delivery, node:http single-header check, and negative cases for the old length-5 bug and substring matching). All CodeRabbit comments have been addressed in 2696d48, including the HTTP_END_CALLED guard. CI shows musl build failures on the first commit but the test lanes appear clean. Given the criticality of the code path and the explicit note that an earlier draft regressed the request-smuggling tests, a maintainer who owns this layer should review the isParsingHttp gate and the parser-loop discard before merge.
I traced every entry into The invariant that makes the discard safe is ordering: the latch is set for request N after its headers parse (before the body branches run), and the discard is at the top of iteration N+1. So a close-flagged request's own body is always consumed and validated first; only bytes received after that body are discarded. That is also why the 26 Per entry path into
On the other two points: the |
|
Status for reviewers (current as of Reproducing the bug against released Bun or Both Diff. Four files (two source, two test).
The three earlier changes-requested reviews (per-header overhead, the duplicate CI. Build 116791 on the current head: all 13 build steps passed, so the change compiles on every platform. One job is red, |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Too much overhead in the C++ changes. Rewrite & simplify.
|
Done in 5b3b203. The three places this was adding per-request work in the C++, and what each became:
What remains per request in the hot path is: one bloom-gated Connection-header scan, one predicted-not-taken branch at the top of the parser loop, and four bool stores. Behavior and tests are unchanged: 17 pass (9 fail on the released 1.4.0), and |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Why do we need both flags.isParsingHttp and response data isParsingHttp?
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 `@packages/bun-uws/src/HttpContext.h`:
- Around line 414-416: The `consumePostPadded()` / `upgrade()` flow can
synchronously destroy `HttpResponseData`, so clearing `isParsingHttp` via the
cached `httpResponseData` pointer is unsafe. Update the `HttpContext` logic to
only touch `httpResponseData->isParsingHttp` after verifying the returned socket
still owns valid HTTP response data, and keep the
`httpContextData->flags.isParsingHttp` reset separate from the stale
response-data write.
In `@packages/bun-uws/src/HttpResponse.h`:
- Around line 159-162: The automatic close-header guard in
HttpResponseData::writeHeader() is relying on wroteConnectionHeader, but that
flag is not being updated when the application writes a Connection header.
Update the writeHeader path so it detects user-supplied Connection headers and
sets wroteConnectionHeader accordingly, keeping the existing close-connection
logic from adding Connection: close when the user has already provided one. Use
the existing HttpResponseData<SSL> state and writeHeader symbol to locate the
right branch.
🪄 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: 1fdc519f-c88e-42e2-a756-d6a461ef9bf2
📒 Files selected for processing (11)
packages/bun-uws/src/HttpContext.hpackages/bun-uws/src/HttpParser.hpackages/bun-uws/src/HttpResponse.hpackages/bun-uws/src/HttpResponseData.hsrc/jsc/bindings/NodeHTTP.cppsrc/runtime/server/HTMLBundle.rssrc/runtime/server/mod.rssrc/runtime/server/server_body.rssrc/runtime/webcore/streams.rssrc/uws_sys/libuwsockets.cpptest/js/bun/http/serve.test.ts
|
We don't. Removed in 27865b4; there is one flag now.
One follow-up on top of that (09f7554): CodeRabbit caught that
|
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
packages/bun-uws/src/HttpContext.h:416— This unconditional write tohttpResponseDataruns after a synchronous WebSocket upgrade has already calledresponseData->~HttpResponseData()andus_socket_adopt()(HttpResponse.h:344/352), so it writes to a destructed object — or, when the adopt keeps the block in place, into a liveWebSocketDatathat was just placement-new'd over the same bytes. Every other post-parserhttpResponseDataaccess in this function is inside theif (returnedData != nullptr)guard at line 431 for exactly this reason; this write should move there too (the companionhttpContextData->flags.isParsingHttp = falseon line 415 is per-context and was always safe).Extended reasoning...
What the bug is.
httpResponseDatais captured at HttpContext.h:262 asus_socket_ext(s)beforeconsumePostPaddedruns. The new line 416 writeshttpResponseData->isParsingHttp = falseunconditionally after the parser returns. But during a synchronous WebSocket upgrade inside the request handler,HttpResponse::upgrade()explicitly callsresponseData->~HttpResponseData()(HttpResponse.h:344) and thenus_socket_adopt(...)(HttpResponse.h:352, with the comment "Adopting a socket invalidates it, do not rely on it directly to carry any data"). After that, the capturedhttpResponseDatapointer no longer refers to a liveHttpResponseData.The two failure modes.
us_socket_adoptcallsus_poll_resize(epoll_kqueue.c:486): ifsizeof(WebSocketData) + sizeof(UserData) > sizeof(HttpResponseData)it allocates a new block and links the old socket ontoloop->data.closed_headfor deferred free (context.c:296‑297) — so line 416 writes to a destructed object on the close-list (formal UB, never read again). If the new ext fits in the old allocation,us_poll_resizereturnspunchanged andwebSocket->init()(HttpResponse.h:363) placement-newsWebSocketDataover the same bytes — so line 416 writes0x00atoffsetof(HttpResponseData, isParsingHttp)into a liveWebSocketData, which is type-punned memory corruption whose effect depends entirely on struct layout.Why the existing code doesn't prevent it. The pre-existing code is explicitly aware of this hazard: the comment at line 430 says "except for nullptr (closed socket, or upgraded socket)", and the only other post-parser
httpResponseDataaccess (line 443) is gated behindif (returnedData != nullptr). The request-handler lambda detects the upgrade viahttpContextData->upgradedWebSocket(line 337‑340) and returnsnullptr, soresult.returnedDataisnullptrand that guard correctly excludes the upgraded socket. Line 416 sits outside that guard, making it the only unguarded post-parser write tohttpResponseDatain the function. The PR's commit message says the new flag is "set and cleared by onData next to the existingisIdlewrite", butisIdleis only written before the parser (line 270), never after — there is no precedent for an unguarded post-parser write here. The same hazard applies to the closed-socket path:onClose(HttpContext.h:237) also destructsHttpResponseData, and the request handler returnsnullptrfor that too.Step-by-step proof.
- A
Bun.serveapp with awebsockethandler receives a WebSocket upgrade request; thefetchhandler callsserver.upgrade(req)synchronously (the common pattern). onDatacaptureshttpResponseData = us_socket_ext(s)at line 262, setsisParsingHttp = trueat line 269, and entersconsumePostPadded.- The request handler runs
HttpResponse::upgrade(): line 344 runsresponseData->~HttpResponseData(); line 352 runsus_socket_adopt, which either reallocates the socket or keeps it in place; line 363 placement-newsWebSocketDataover the ext bytes; line 371 setshttpContextData->upgradedWebSocket. - Back in
onData's request-handler lambda, line 337 seesupgradedWebSocketset and returnsnullptr, soresult.returnedData == nullptr. - Line 416 executes
httpResponseData->isParsingHttp = false— a write through a pointer to a destructed object whose storage now holds either nothing (close-list) or a liveWebSocketData. - Line 431's
if (returnedData != nullptr)is false, so the function falls through to the upgraded-websocket branch at line 457 — but the stray write has already happened.
Impact. The PR description says
websocket-server*.test.tspasses, so the corruption is currently silent — most likely Bun'sUserDatamakesWebSocketData + UserDatalarger thanHttpResponseData(the realloc case), or the byte happens to land on padding/a benign field. But that is layout-dependent and can change with any field addition to either struct, any compiler/platform alignment difference, or a smallerUserData. It is a write-after-lifetime-end on every synchronous WebSocket upgrade regardless.Fix. Move
httpResponseData->isParsingHttp = false;inside theif (returnedData != nullptr)block at line 431 (e.g. right after the guard opens), matching the existing convention. The flag is only read byuncorkAndCloseIfNeeded()to defer the socket close toonData's tail, and an upgraded/closed socket has no such tail to defer to, so clearing it there is unnecessary anyway. The companionhttpContextData->flags.isParsingHttp = falseat line 415 is on per-context data which is never adopted and can stay where it is. - A
|
On the "Additional findings" item about The fix is exactly what this finding proposes: the clear moved inside the existing |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Can the 6 booleans and the uint8_t state be combined into a single uint16_t state?
|
Four of the six, done in e14567a.
The other two can't become
Both keep a short comment on the struct saying why. Getting them in too would need |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
packages/bun-uws/src/HttpResponse.h (2)
510-513: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winKeep this framing comment within the comment-length limit.
This block is four lines.
Proposed trim
- /* When the user wrote an explicit Transfer-Encoding header but never started - * streaming writes (a one-shot end), the body must still be chunk-framed. - * Terminate the headers and enter chunked mode so internalEnd() takes the - * chunked path instead of writing the raw body after the headers. */ + /* With explicit Transfer-Encoding and one-shot end, terminate headers + * and enter chunked mode so internalEnd() frames the body. */As per coding guidelines, “Keep code comments to 3 lines max.”
🤖 Prompt for 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. In `@packages/bun-uws/src/HttpResponse.h` around lines 510 - 513, Shorten the framing comment in HttpResponse::internalEnd to fit the 3-line comment limit while preserving the key behavior: explicit Transfer-Encoding with a one-shot end must still enter chunked mode so the body is chunk-framed. Keep the reference to the headers being terminated and internalEnd() taking the chunked path, but merge the four-line explanation into at most three concise lines.Source: Coding guidelines
499-503: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winCondense the close-delimited response comment.
This comment block is five lines.
Proposed trim
- /* Close-delimited responses (the user removed the framing headers): - * raw body bytes after the headers, no Content-Length, no chunked - * framing, and the connection closes to delimit the message. The - * CONNECTION_CLOSE state is set directly so internalEnd() closes the - * socket without adding a Connection: close header the user removed. */ + /* Close-delimited responses: raw body bytes with no Content-Length or + * chunked framing; close delimits the message. Set close directly so + * internalEnd() does not re-add a removed Connection header. */As per coding guidelines, “Keep code comments to 3 lines max.”
🤖 Prompt for 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. In `@packages/bun-uws/src/HttpResponse.h` around lines 499 - 503, Condense the close-delimited response comment in HttpResponse-related handling so it fits the 3-line max guideline. Keep the explanation around the close-delimited flow and CONNECTION_CLOSE behavior, but remove redundant wording in the comment block near the logic that sets CONNECTION_CLOSE and calls internalEnd().Source: Coding guidelines
src/jsc/bindings/NodeHTTP.cpp (1)
728-731: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winShorten this sentinel comment to three lines.
The block is four lines; keep the invariant concise.
Proposed trim
- // node:http marks framing decisions with a NUL-named sentinel - // pair instead of a real header: value "1" = close-delimited - // (the user removed the framing headers), value "2" = no body - // (HEAD - suppress all body framing like 204/304). + // node:http uses a NUL-named sentinel: value "1" means + // close-delimited, value "2" means no body (HEAD-style + // framing suppression, like 204/304).As per coding guidelines, “Keep code comments to 3 lines max.”
🤖 Prompt for 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. In `@src/jsc/bindings/NodeHTTP.cpp` around lines 728 - 731, Shorten the sentinel comment in NodeHTTP::addHeaders to three lines by trimming the explanatory wording while preserving the key invariant about the NUL-named header sentinel and the meanings of values "1" and "2". Update only the comment block near the framing decision logic so it remains concise and within the 3-line coding guideline.Source: Coding guidelines
🤖 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.
Outside diff comments:
In `@packages/bun-uws/src/HttpResponse.h`:
- Around line 510-513: Shorten the framing comment in HttpResponse::internalEnd
to fit the 3-line comment limit while preserving the key behavior: explicit
Transfer-Encoding with a one-shot end must still enter chunked mode so the body
is chunk-framed. Keep the reference to the headers being terminated and
internalEnd() taking the chunked path, but merge the four-line explanation into
at most three concise lines.
- Around line 499-503: Condense the close-delimited response comment in
HttpResponse-related handling so it fits the 3-line max guideline. Keep the
explanation around the close-delimited flow and CONNECTION_CLOSE behavior, but
remove redundant wording in the comment block near the logic that sets
CONNECTION_CLOSE and calls internalEnd().
In `@src/jsc/bindings/NodeHTTP.cpp`:
- Around line 728-731: Shorten the sentinel comment in NodeHTTP::addHeaders to
three lines by trimming the explanatory wording while preserving the key
invariant about the NUL-named header sentinel and the meanings of values "1" and
"2". Update only the comment block near the framing decision logic so it remains
concise and within the 3-line coding guideline.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5631f141-54f3-417d-a0cc-2d6ff3c1f96f
📒 Files selected for processing (6)
packages/bun-uws/src/HttpContext.hpackages/bun-uws/src/HttpResponse.hpackages/bun-uws/src/HttpResponseData.hsrc/jsc/bindings/NodeHTTP.cppsrc/uws_sys/libuwsockets.cpptest/js/node/http/node-http.test.ts
|
All three of these comments already exist on
Rewording pre-existing comments is outside the scope of this PR, so I am leaving them as they are. The comments this PR does add are all 3 lines or fewer. |
|
The What I confirmed before changing anything:
While anchoring the edit I found a second function with the same end-of-function shape, |
There was a problem hiding this comment.
No issues found in the rebased diff — both post-rebase findings (gate-before-getHeaders(), tunnel-check ordering) are addressed in 473daf1 and the 8 new tests cover the close variants, controls, and malformed-trailing-bytes case. Deferring to a human because this is the per-request hot path in fenceAndConsumePostPadded and the earlier revision drew maintainer feedback on parser overhead; the remaining diff is small but a human should sign off on core HTTP parser control-flow changes.
What was reviewed:
hasConnectionClose()token-list scan: mirrorsgetTransferEncoding()'s shape, handles multipleConnectionlines, OWS, mixed case; bloom-filter fast path preserved.- Bun.serve discard path returns
success(consumedTotal + length, user)so nothing lands in the fallback buffer andonData's existingshouldCloseConnection()tail closes; traced the cross-segment andConsumeMinimallyfallback paths. - Tunnel check stays above the gate so node:http Upgrade-with-body is unaffected; the gate is above
getHeaders()so malformed trailing bytes are discarded rather than 400'd. - The
nope!control confirms the oldlength() == 5false-positive is gone.
Extended reasoning...
Overview
Three-file rebase of a longer earlier revision. HttpParser.h: (1) new HttpRequest::hasConnectionClose() that scans every Connection header line as a comma-separated, OWS-trimmed, case-insensitive token list (same shape as the existing getTransferEncoding()); (2) the per-connection nodeHttpSawConnectionClose latch is renamed sawConnectionClose and its if constexpr (IsNodeHttp) guard dropped so it applies to Bun.serve too; (3) the loop-top gate now sits after the IsNodeHttp && isConnectRequest tunnel check but before getHeaders(), and for Bun.serve returns success(consumedTotal + length, user) to discard the rest of the buffer. HttpContext.h: the dispatch lambda's getHeader("connection").length() == 5 becomes hasConnectionClose(). serve.test.ts: 8 raw-socket tests (5 close spellings, 2 persistent-connection controls, 1 malformed-trailing-bytes).
Security risks
This is RFC 9112 §9.6 compliance — the same neighborhood as request-smuggling protections. The change is strictly a tightening (fewer requests dispatched, not more): a pipelined request after Connection: close is now discarded, and the token scan replaces a heuristic that both under-matched (keep-alive, close) and over-matched (any 5-byte value). I traced the discard path to confirm nothing reaches the fallback buffer and no second response is written, including for malformed trailing bytes (the 2302116 hoist). The tunnel-check ordering fix in 473daf1 keeps node:http Upgrade-with-body flowing. I did not find a way for the new gate to cause a request that should be served to be dropped.
Level of scrutiny
High. fenceAndConsumePostPadded is the per-request parse loop for every HTTP/1.x connection in both Bun.serve and node:http, and the earlier revision of this PR drew explicit maintainer changes-requested over per-header C++ overhead. That feedback was about code no longer in this diff, but a human should confirm the now-unconditional hasConnectionClose() scan (called once in the parser latch and once in the dispatch lambda per request) is acceptable in the hot path — it's a linear header walk behind a bloom-filter fast path, same cost class as getTransferEncoding(), but it's their call.
Other factors
All prior review threads (bot-only; no human reviewer on record) are resolved. Both of my post-rebase findings were fixed exactly as suggested. The tests are hermetic (raw net.connect to port: 0), settle on server close or an over-count fast-fail, and assert both the handler-side (handled) and wire-side (responses, body markers) invariants. request-smuggling.test.ts and the node:http HPE_CLOSED_CONNECTION test were reported passing by the author. The diff line count in the PR body (hasConnectionClose is 39 lines) is the largest single addition; everything else is a rename, a guard removal, and a block move.
RFC 9112 9.6: a server that receives a "close" connection option MUST
NOT process any further requests received on that connection. The
node:http personality already enforced this via a parser-level
sawConnectionClose latch; Bun.serve's dispatch loop kept iterating over
every well-formed request in the recv buffer, and the per-request state
reset wiped the previous request's HTTP_CONNECTION_CLOSE bit so nothing
ever closed.
The latch now applies to both personalities. node:http still raises
HPE_CLOSED_CONNECTION on further bytes; Bun.serve discards them so the
existing shouldCloseConnection() tail in onData closes the socket once
the final response flushes.
The Connection header is now scanned as a comma-separated token list
(hasConnectionClose(), same shape as getTransferEncoding()) instead of a
bare length() == 5 check, so "Connection: keep-alive, close" is
recognised and a five-byte non-close value ("nope!") is not.
The gate sat after getHeaders() and its isError() return, so a malformed pipelined request behind a close-flagged one was still parsed and Bun.serve's error path wrote a canned 400 onto the wire (a second status line), or aborted the first response if its handler was still pending. Move the gate to the top of the for(;length;) loop so trailing bytes are never parsed once the latch is set; only the latch (which needs the parsed request) stays where it is. This also aligns node:http with llhttp: further bytes after a close-flagged message always produce HPE_CLOSED_CONNECTION, not whatever parse error the garbage happens to hit.
Hoisting the gate to loop-top put it before the IsNodeHttp tunnel check, so a node:http Upgrade-with-body whose Connection header also carried a close token (e.g. "Connection: Upgrade, close", or HTTP/1.0) would hit the gate on the first post-body iteration and return HPE_CLOSED_CONNECTION instead of routing the bytes to the upgrade socket. A switched-protocol connection is no longer HTTP, so RFC 9112 9.6 does not apply to it; the tunnel check must win. Restore the tunnel check to its pre-hoist position (first in the loop), with the gate immediately after it and still before getHeaders(). Bun.serve is unaffected: the tunnel check is IsNodeHttp-only.
One write of GET /0, GET /1 (Connection: close), GET /2, GET /3. Since #42762 keeps the socket corked across the requests of one read, main answers all four and leaves the connection open. The parser latch stops at /1: two responses, then the server closes. roundTrip() now reports whether the server closed the socket, and the close-variant cases assert it.
473daf1 to
eef27aa
Compare
The response to the close-flagged request is still pending when the parser reaches the pipelined one. On main the pipelined request takes the pending-response close and the first response is never sent.
Hoisting the gate to the top of the loop also put it above node:http's leading CR/LF skip. With the response still pending, a close-flagged request followed by a stray CRLF (the extra CRLF some clients send after a POST body, RFC 9112 2.2, or a blank line after an HTTP/1.0 request) then raised HPE_CLOSED_CONNECTION, and the default 'clientError' handler destroys the socket before the response goes out. llhttp's closed state skips CR and LF and only raises the error on other bytes, and main tolerated these too. Move the gate below the skip. It stays above getHeaders(), so other bytes are still never parsed: they raise HPE_CLOSED_CONNECTION, not whatever parse error they would produce. Bun.serve has no CR/LF skip, so its discard is unchanged.
There was a problem hiding this comment.
I re-reviewed after bdce248 and didn't find any new issues — the sawConnectionClose gate now sits below the node:http CR/LF skip and above getHeaders(), with tests covering both the trailing-CRLF and non-CRLF-bytes cases. Given this is the HTTP/1.1 request-boundary parser and there are outstanding changes-requested reviews from a maintainer, a human sign-off is still the right next step.
Checked: hasConnectionClose() token-scan bounds (all pos/tokenEnd accesses guarded, strncasecmp length-gated); gate ordering vs. tunnel/park/CRLF-skip matches the comment's claims; no remaining getHeader("connection").length() == 5 sites; the Bun.serve discard path returns consumedTotal + length so onData's tail still runs its close check.
Extended reasoning...
Overview
The PR replaces a naive length() == 5 heuristic with a proper token-list scanner (HttpRequest::hasConnectionClose()) and generalizes the previously node:http-only sawConnectionClose latch so Bun.serve also stops dispatching pipelined requests after a Connection: close (RFC 9112 §9.6). Since my last review, commit bdce248 reordered the gate to run after the node:http leading-CR/LF skip in fenceAndConsumePostPadded, so a stray CRLF after a close-flagged POST body or HTTP/1.0 request no longer raises HPE_CLOSED_CONNECTION before the response goes out — matching llhttp's s_n_llhttp__internal__n_closed, which loops on \r/\n and errors only on other bytes. The commit also added three node-http.test.ts cases covering both sides of that behavior. The gate remains above getHeaders(), below the tunnel check, and below the park check, and each ordering constraint in the comment block is accurate against the surrounding code (HttpParser.h:1151/1159/1173/1196/1202).
Security risks
This is the HTTP/1.1 request-boundary logic — directly adjacent to request-smuggling territory. The change tightens behavior (fewer requests dispatched, more accurate close detection) rather than loosening it, and the discard path for Bun.serve returns success(consumedTotal + length, user) so the caller's post-loop close check still fires with HTTP_CONNECTION_CLOSE set. The token scanner walks untrusted header bytes, but every index is bounds-checked against value.length() before dereference and the strncasecmp is gated on tokenEnd - tokenStart == 5. I found no new attack surface, but parser-boundary changes in a request-smuggling-sensitive loop warrant a maintainer's eyes regardless.
Level of scrutiny
High. packages/bun-uws/src/HttpParser.h is the hand-rolled HTTP parser shared by both Bun.serve and node:http compat; a mistake here is a request-smuggling or DoS vector for every Bun HTTP server. The diff is small and the ordering is now well-commented, but three CHANGES_REQUESTED reviews from Jarred-Sumner remain on the PR without a subsequent approval. The PR description states those reviews targeted code since removed from the diff (the response-header / bitfield refactor), which is consistent with the current 4-file diff, but I cannot verify the maintainer's concerns are cleared from metadata alone.
Other factors
Test coverage is solid for the fix itself: serve.test.ts covers pipelined-after-close, close-mid-read, and async-handler cases; node-http.test.ts now covers the CRLF-tolerance and HPE_CLOSED_CONNECTION error-code cases. I confirmed no getHeader("connection").length() == 5 call sites remain in the tree. The scope is now tightly focused on the §9.6 MUST (the SHOULD half — response Connection: close header — was dropped per the description), which keeps the diff reviewable.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
don't we already track this in a bool somewhere?
|
Yes, and that bool is the one this PR uses. It does not add a field.
The other place this is tracked is the
|
|
A gap check against this branch. A second fix for the same report was in progress on another branch (linked below). It is not opened as a PR, to keep one PR for the report. Verified on debug builds of
Cause. The latch reads the request head. A close mark from the response side ( A patch that closes it. One test of the existing HttpResponseData<SSL> *httpResponseData = (HttpResponseData<SSL> *) us_socket_ext((us_socket_t *) s);
/* Bun.serve, RFC 9112 9.6: a complete earlier response closes this
* connection (Connection: close, HTTP/1.0), so the requests behind it
* are not processed; resetResponseState() below would drop the mark.
* Same steps as onData's tail. Runs before the timeout reset: a socket
* that has not drained keeps its timeout until onWritable closes it. */
if constexpr (!IsNodeHttp) {
constexpr uint32_t closeOrPending = HttpResponseData<SSL>::HTTP_CONNECTION_CLOSE | HttpResponseData<SSL>::HTTP_RESPONSE_PENDING;
if ((httpResponseData->state & closeOrPending) == HttpResponseData<SSL>::HTTP_CONNECTION_CLOSE) [[unlikely]] {
us_socket_unref((us_socket_t *) s);
((AsyncSocket<SSL> *) s)->uncork();
((HttpResponse<SSL> *) s)->closeIfDoneAndMarked(httpResponseData);
return nullptr;
}
}Commit 1fff7d4 on
With both applied the rest of "requests pipelined in one read" also passes, including the one Two options: fold the patch and the two response header cases in here, or I send them as a follow-up after this lands. Either is fine with me. |
…nection twice The parser already records whether the request forbids keep-alive before it calls the handler. The dispatch code now reads that flag, so each request scans its Connection header once. Also shortens the comments around it.
There was a problem hiding this comment.
I reviewed the latest push (the merge of main plus the dispatch-time read of sawConnectionClose) and found no bugs; since this is the HTTP/1.1 request parser on the network-facing path and a maintainer changes-requested review is still on the record, a human look is still worthwhile.
What was reviewed:
- The dispatch refactor at
packages/bun-uws/src/HttpContext.h:494is behavior-preserving: the latch is set atHttpParser.h:1212beforerequestHandlerruns for the same request, and the parser never dispatches again once it is set, so the flag is never read for a request other than the one that set it. hasConnectionClose()token walk: every iteration advancespos(past a comma or to end of value),tokenEnd > tokenStartguards the trailing-OWS trim, and the 5-bytestrncasecmpis gated on token length, so it does not read pastvalue.- The gate now sits below the node:http CRLF skip, so the trailing-CRLF-after-POST case raised earlier is handled by the code and exercised by the two new
node-http.test.tscases; nogetHeader("connection").length() == 5callers remain in the tree. - The node:http async-pipelined branch (
startPipelinedResponseImpl) takesconnectionClosefrom JS and is untouched by this change.
Extended reasoning...
Overview
The diff against the base touches two native files and two test files. packages/bun-uws/src/HttpParser.h adds HttpRequest::hasConnectionClose() (a comma-separated, OWS-trimmed, case-insensitive token scan over every Connection header, gated on the bloom filter), promotes the node-only nodeHttpSawConnectionClose latch to a public sawConnectionClose on HttpParser that is set for both server types right after getHeaders(), and adds a loop-top gate below the tunnel/park/CRLF-skip blocks that returns HPE_CLOSED_CONNECTION for node:http and silently consumes the remainder of the read for Bun.serve. packages/bun-uws/src/HttpContext.h replaces the getHeader("connection").length() == 5 heuristic at dispatch with a read of that latch (the maintainer's own last commit). Tests were added to test/js/bun/http/serve.test.ts (10 cases: close/Close/keep-alive, close/close, TE/HTTP 1.0, mid-read close, async handler, two keep-alive controls, malformed trailing bytes) and test/js/node/http/node-http.test.ts (trailing CRLF after a close-flagged POST or HTTP/1.0 request is not a clientError; other trailing bytes are HPE_CLOSED_CONNECTION).
Security risks
This is request-parsing code on network bytes, adjacent to request-smuggling concerns. I checked the new token scanner for out-of-bounds reads (every index is bounded by value.length(), the trailing-whitespace trim is guarded by tokenEnd > tokenStart, and the 5-byte compare only runs when the token is exactly 5 bytes) and for termination (each outer iteration either consumes a comma or reaches the end of the value). The Bun.serve discard path returns consumedTotal + length so the caller sees the whole read consumed and nothing spills into the fallback buffer; the close-flagged request's own body is consumed in the same iteration before the gate can fire on the next iteration, so body framing is unchanged. Widening the latch from node-only to both server types is a strict tightening (fewer requests dispatched), not a relaxation.
Level of scrutiny
High. The change alters which pipelined requests are dispatched and when a connection is closed, in the shared uWS parser used by both Bun.serve and node:http. The last push is by the maintainer who previously requested changes, and there is no independent non-author sign-off recorded, so I am not approving: a human should confirm the maintainer's own refactor lands the semantics they intended. The dispatch-time read of the latch is behavior-preserving as far as I can trace it: the latch is set at HttpParser.h:1212 before requestHandler(user, req) at line 1334 in the same iteration, and once set the gate at line 1181 prevents any later dispatch, so the flag cannot be observed on a different request than the one that set it. The HttpResponseData is placement-new'ed per socket in onOpen, so the latch has the connection lifetime the RFC requires.
Other factors
The earlier finding I posted about the gate firing on a stray CRLF after a Connection: close POST is addressed in the current code (the gate sits below the isNewline skip) and the two node-http test cases target exactly that input. The raw-socket helpers in the tests settle on either the server close or the expected response count, so a regressed server that answers /b and keeps the socket open still fails fast rather than hanging; closedByServer is captured before socket.destroy(). The remaining risk is whether CI on all platforms agrees with the debug-build results claimed in the description, which the automated review cannot verify here. Overall: no bugs found by the hunt (exit reason dry_streak), but network-parser scope plus an outstanding maintainer review state means defer rather than approve.
#33005 stops the read at a request that carries Connection: close, in the parser. The dispatch check stays for a close that the response sets (a Connection: close response header), which the parser cannot see. The tests keep the response cases only.
…connection (#42986) ### Problem - A `Bun.serve` handler answers with a `Connection: close` header. The requests behind it in the same read still run and the connection stays open (RFC 9112 9.6 forbids both). Regression from #42762 for the middle of a read: 1.4.2 answers `R:0 R:1` and closes, `main` da60a4b answers `R:0 R:1 R:2 R:3`. - #33005 fixed the request side with a parser latch that reads the request head. A close from the response only sets `HTTP_CONNECTION_CLOSE`. The next dispatch calls `resetResponseState()` (`packages/bun-uws/src/HttpContext.h`) and clears it before the close gate at the end of `onData`. ### Fix - The dispatch lambda in `onData` tests the bit before the reset. If the connection is marked close and its response is complete, it sets the latch of #33005, sends the cork buffer, runs the close gate and stops the parse. - A socket that has not drained stays open until `onWritable` closes it. The latch makes the parser discard later bytes on it. - Correct because every setter of the bit means "close after this response". `node:http` is not changed. - Verified: `test/js/bun/http/serve.test.ts`, "does not run the requests behind a response that closes the connection": 6 cases fail on `main`, pass here. ### Background - `onData` parses every request of one read and calls the dispatch lambda for each. `resetResponseState()` starts a new response in the socket's `state` word. - Close gate: `closeIfDoneAndMarked()` closes a socket that is marked close, has no pending response and no unsent bytes. - Cork: uWS collects a socket's writes and sends them with one `send()`. <details><summary>Notes</summary> **Repro** (one file). The handler of request 1 sets the header: ```js import net from "node:net"; const server = Bun.serve({ port: 0, fetch(req) { const n = new URL(req.url).searchParams.get("n"); return new Response("R:" + n, n === "1" ? { headers: { Connection: "close" } } : undefined); }, }); const r = n => `GET /?n=${n} HTTP/1.1\r\nHost: x\r\n\r\n`; const s = net.connect(server.port, "127.0.0.1", () => s.write(r(0) + r(1) + r(2) + r(3))); let buf = ""; s.on("data", d => (buf += d)); s.on("end", () => console.log("the server closed the connection")); setTimeout(() => { console.log("answered:", buf.match(/R:\d/g).join(" ")); process.exit(0); }, 500); ``` 1.4.2 and this branch: `the server closed the connection`, `answered: R:0 R:1`. `main` da60a4b and canary c6b7fcb: `answered: R:0 R:1 R:2 R:3`, no close. **How 1.4.2 closed.** The first response of a read released the cork. A later response ended on a socket that was not corked, and `internalEnd` ran the close gate inside the handler. Since #42762 the socket that `onData` parses leaves the gate to the end of `onData`. The first response of a read always ended corked, so a close at the start of a read was never honoured. **Why the check is at the next dispatch and not in `internalEnd`.** A close inside the handler skips the validation of the rest of that request's body. `request-smuggling.test.ts` ("chunk size strict hex digit validation") sends an invalid chunk size to a handler that answers at once, and expects the 400. At the next request boundary the body of the closing request is consumed and validated. **Shapes.** One write per row. `rc` is a request that the handler answers with `Connection: close`. Handlers that ran, then the state of the connection: | payload | 1.4.2 | `main` da60a4b | this branch | |---|---|---|---| | `a, rc, b, c` | a rc, closed | a rc b c, open | a rc, closed | | `static, rc, static, b` | rc, closed | rc b, open | rc, closed | | `rc, b, c` | rc b c, open | rc b c, open | rc, closed | | `POST rc` + body (handler awaits `req.text()`), `b` | rc b, open | rc b, open | rc, closed | | `rc` with a stream body, `b` | rc b, open | rc b, open | rc, closed | | `a, rc` and `rc` alone | closed | closed | closed | | `rc`, then bytes that are not HTTP | 200, 400, closed | same | same | | `POST rc` with an invalid chunk size | 200, 400, closed | same | same | | `a, b, c` (keep-alive) | a b c, open | same | same | **A socket that has not drained.** Checked by hand with an `LD_PRELOAD` shim that cuts the `send()` of the `rc` response in half and returns `EAGAIN` until a file appears. The suite has no test for this. It needs the shim. | first write, then a later write | `main` da60a4b | this branch | |---|---|---| | `a, rc, b`, then `c` | runs `b`, then `c` resets the connection with the `rc` response cut short (the close for a request behind a pending response) | runs `a rc`, sends the rest of `rc` after the unblock, closes | | `a, rc, POST b` + body, then `d` | same as above | same as above | | `a, rc`, half of the head of `b`, then the rest of the head and `d`, then `e` | | same as above | The second row is why the early exit sets `sawConnectionClose`. The parser records the `Content-Length` of a request before it calls the dispatch lambda, and it keeps a split head in its fallback buffer. Without the latch the later read was parsed against that state, failed, and the error path closed the socket with the `rc` response cut short (a review finding on the first push). With the latch those bytes go to a null body callback or to the discard at the top of the parse loop. **Randomized probe.** 400 pipelined batches of 1 to 8 requests (GET, static route, POST with `Content-Length`, chunked POST), a closer at a random position in three of four batches (request side: `Connection: close`, HTTP/1.0; response side: plain, after `await req.text()`, stream body), plain and TLS. The model: every request up to the closer runs and is answered, then the server closes. This branch: 0 mismatches in 700 batches. `main`: 27 of 80, all on the response side. **Not changed.** - Bytes that are not HTTP behind an `rc` response still get a 400 before the close. The parser rejects them before any dispatch. #33005 discards them for the request side. - A request behind a response that is still pending closes the connection at once, as before. - A static route whose `Response` carries `Connection: close` sends the header and does not close (1.4.2 too). That path never sets the bit. It is a separate bug and is tracked separately. - A graceful `stop()` in the middle of a read still answers the rest of the read and then closes. **Related.** #38343 keeps `HTTP_CONNECTION_CLOSE` across `resetResponseState()`. With it the requests behind still run and the connection closes at the end of the read. After #33005 and this change no request is dispatched on a marked connection with a complete response, so the reset no longer drops a live mark. **Suites run with the debug build.** `serve.test.ts` (320 pass), `request-smuggling.test.ts` (89), `bun-server.test.ts` (80), `http-server-chunking.test.ts`, `bun-serve-static.test.ts`, `bun-serve-routes.test.ts`, `bun-serve-file.test.ts`, `bun-serve-headers.test.ts`, `serve-close-delimited-framing.test.ts`, `serve-direct-readable-stream.test.ts`, `hspec.test.ts`, `proxy.test.ts`, `fetch-keepalive.test.ts`, `node-http.test.ts` (162). Two tests of `serve.test.ts` (`root range port`, `/bun:info` loopback) fail the same way on a debug build of `main` in this container. </details>
Problem
Bun.serveanswers requests pipelined behind aConnection: closerequest. One write ofGET /a(Connection: close) andGET /bgets two 200 responses, and the socket stays open. RFC 9112 9.6: the server MUST NOT process further requests. Node answers/aand closes.fenceAndConsumePostPadded(packages/bun-uws/src/HttpParser.h) dispatches every request of a read. The latch that stops it wasnode:httponly. The next dispatch callsresetResponseState(), which clearsHTTP_CONNECTION_CLOSE./0,/1(close),/2,/3gets four responses.getHeader("connection").length() == 5also misseskeep-alive, close.Fix
sawConnectionCloselatch applies to both server types. At the loop topBun.servediscards the rest of the buffer.node:httpstill returnsHPE_CLOSED_CONNECTION.resetResponseState().HTTP_CONNECTION_CLOSEstays set, and the existing check at the end ofonDatasends the responses and closes.HttpRequest::hasConnectionClose()scans eachConnectionline as a case-insensitive token list. The parser calls it once per request, to set the latch before the handler runs. The dispatch code inHttpContext.hreads the latch, so a request scans itsConnectionheader once. Bothlength() == 5checks are gone.test/js/bun/http/serve.test.ts, "does not dispatch a pipelined request after Connection: close" (10 cases, 8 fail onmain). Alsorequest-smuggling.test.ts,node-http.test.ts, 44test-http-*files.Background
onData(HttpContext.h) receives one TCP read and runs the parser loop over it. The loop calls the handler for each complete request.HttpResponseData::stateis one word per socket.resetResponseState()clears its per-response bits at each dispatch.boolon the per-socketHttpParser, so it also covers a later read.Notes
Repro. With
Bun.serve({ port: 0, fetch: req => new Response(new URL(req.url).pathname) })listening:mainruns the handler for/aand/b, writes two 200 responses and leaves the socket open. With this change: one response, then the server closes.Where the gate sits. It is the last check before
getHeaders(). Malformed bytes behind the close-flagged request are discarded. They do not produce a 400. It runs after threenode:httpblocks: the tunnel check (a switched-protocol connection is no longer HTTP), the flood-prevention park block, and the leading CR/LF skip. Parked bytes go throughfeedNodeHttpDatawhen reads resume, so they reach the gate then. That keeps the ordermainhad fornode:http: park first,HPE_CLOSED_CONNECTIONsecond. The CR/LF skip runs first because llhttp's closed state also skips CR and LF and raisesHPE_CLOSED_CONNECTIONonly on other bytes, so the extra CRLF some clients send after a POST body still gets its response (three cases innode-http.test.tscover this). All three blocks are compiled out forBun.serve, where the gate is the first thing in the loop.The latch is set before the handler runs. An async handler gets the same discard. On
mainthe pipelined request finds the response to/astill pending and takes the "request behind a pending response" close in the dispatch lambda, so the response to/ais never sent (0 bytes, then close). With the discard the response is sent when the handler resolves, andinternalEndcloses after it. The body of the close-flagged request itself is consumed in the same loop iteration, before the gate can run. The chunk-size cases inrequest-smuggling.test.tsstill return 400.Test results. Debug build (ASAN),
mainat 7d792a5, the new describe block:packages/frommainThe canary passes the middle-of-a-read case: before #42762 the first response of a read released the cork, so the response to
/1ran the close check ininternalEndand closed there. #42762 keeps the cork for the whole read, so that path no longer runs. The parser latch does not depend on the cork state.Cases. Over a raw socket, one write each:
close,Close,keep-alive, close,close, TE, HTTP/1.0: one response, handler runs for/aonly, server closes./0,/1(close),/2,/3: two responses, handler runs for/0and/1, server closes./a(close) with an async handler, then/b: the response to/aarrives, then the server closes.Connection: nope!(five bytes) and noConnectionheader: both requests are served.@@@ not HTTPbehind the close-flagged request: one response, no 400.Suites run with the debug build.
serve.test.ts(311 pass),request-smuggling.test.ts(89),node-http.test.ts(159),node-http-req-socket-pause.test.ts,node-http-parser.test.ts,node-http-connect.test.ts,bun-server.test.ts,http-server-chunking.test.ts,bun-serve-headers.test.ts,serve-close-delimited-framing.test.ts,hspec.test.ts, and 44test/js/node/test/parallel/test-http-*files (pipeline, keep-alive, upgrade-server, smuggling, server-close, 1.0). The failures also fail on a debug build ofmainin the same container: root-range port and/bun:infoloopback (the container runs as root), two IPv6 tests (no IPv6), and thenode-http-connectwrapper that runs its 7 inner tests in 5.6 s against a 5 s timeout.Scope. This PR is the MUST NOT half of 9.6 plus the token-list detection. The
Connection: closeresponse header (the SHOULD half) is not part of it. Earlier revisions of this PR carried it, along with astatebitfield refactor thatmainhas since absorbed. The changes-requested reviews from June refer to that code. It is no longer in the diff. #33878 is closed as superseded.no test proof · iteration 21 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/http/serve.test.ts