Conversation
HttpResponseData::resetResponseState() runs for every request dispatched on a connection and cleared HTTP_CONNECTION_CLOSE along with the per-response framing bits. A well-formed HTTP/1.1 request pipelined behind an HTTP/1.0 request, a Connection: close request, or a response that carried Connection: close therefore turned the connection persistent again and the close recorded for the earlier request never happened. Until bdb7382 the HTTP/1.0 case was masked by the parser latching its ancient flag for the rest of the recv buffer, which re-marked the connection on the pipelined request; that flag is per-request now. The mark describes the connection, not the response in flight, so add it to HTTP_CONNECTION_SCOPED, the set of bits resetResponseState() preserves. The pipelined request is still answered and the existing shouldCloseConnection() gates close the socket once that response has flushed.
|
Warning Review limit reached
Next review available in: 45 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 1:05 AM PT - Aug 14th, 2026
❌ @robobun, your commit 790a5bc has some failures in 🧪 To try this PR locally: bunx bun-pr 38343That installs a local version of the PR into your bun-38343 --bun |
|
Status: reproduced on current main and fixed here; ready for a maintainer. Reproduced with the raw-socket repro in the description (HTTP/1.0 request + pipelined HTTP/1.1 request in one segment): on main ( CI (build 95379): 177 of 179 jobs passed, including every other darwin lane; the remaining 2 are |
There was a problem hiding this comment.
LGTM — one-bit addition to HTTP_CONNECTION_SCOPED so resetResponseState() no longer wipes a pending close mark when a pipelined request is dispatched.
What was reviewed:
- Grepped every reader/writer of
HTTP_CONNECTION_CLOSEacrosspackages/bun-uws/andsrc/: all setters are|=-only, nothing intentionally clears it, and fresh connections zero-initstate, so making it sticky is safe. - Checked both
resetResponseState()callers (HttpContext.hrequest lambda,JSNodeHTTPServerSocket::startPipelinedResponse) — the latter's manualconnectionClosere-apply is now redundant but harmless. - Verified all
shouldCloseConnection()gates still guard onHTTP_RESPONSE_PENDINGcleared +hasFullyDrained(), so the pipelined response is still delivered before FIN. - Test: event-driven (no sleeps),
port: 0,usingcleanup, wireserror/end/close, includes a keep-alive control case;netalready imported at serve.test.ts:27.
Extended reasoning...
Overview
Single-line native change in packages/bun-uws/src/HttpResponseData.h: adds HTTP_CONNECTION_CLOSE to the HTTP_CONNECTION_SCOPED mask, plus a clarifying comment on the enum member. resetResponseState() masks the state word down to HTTP_CONNECTION_SCOPED at the start of every request dispatched on a connection, so before this change a pipelined HTTP/1.1 request in the same recv buffer would erase the close mark set by an HTTP/1.0 or Connection: close request ahead of it. A new 6-case describe block in test/js/bun/http/serve.test.ts covers the five non-persistent first-request shapes plus a keep-alive control.
Security risks
None. This tightens behaviour toward RFC 9112 §9.3/9.6 (a connection marked non-persistent stays non-persistent) and restores released-build behaviour for the HTTP/1.0 shapes regressed by #35864. No new input parsing, no allocation, no untrusted data handling — one bit added to a compile-time mask.
Level of scrutiny
Medium: the flag word is shared connection-lifetime state on a hot path (Bun.serve and node:http compat). I verified the PR's central claim by grepping every use of HTTP_CONNECTION_CLOSE: HttpContext.h:440/442, HttpResponse.h:156/584, libuwsockets.cpp:1414/1440, NodeHTTP.cpp:838, and JSNodeHTTPServerSocket.cpp:254/541 all only |= the bit; nothing ever &= ~ it. The only place that could clear it was resetResponseState() itself, which is exactly the bug. The one read that gates a side effect on the bit being unset (HttpResponse.h:152, deciding whether to emit a Connection: close header) is inside if (closeConnection) and only skips a redundant header write — the connection still closes via the existing shouldCloseConnection() gates, all of which check HTTP_RESPONSE_PENDING == 0 && hasFullyDrained() before shutting down, so the pipelined response is still delivered.
Other factors
The node:http pipelining path (startPipelinedResponseImpl) already worked around this by re-applying a captured connectionClose after resetResponseState(); that re-apply is now a no-op OR of an already-set bit, which is harmless. The Rust-side readers (NodeHTTPResponse.rs is_http_connection_close()) feed the bit back into end(..., closeConnection), which composes correctly. The test is well-constructed per repo guidelines: no sleeps (races "server closed" vs "probe answered" via socket events), port: 0, using server, wires error to a no-op so the following close still resolves, destroys the socket after settling, and includes a positive control. The PR description documents that all five failing cases fail on current main and pass with the fix, and that the surrounding HTTP suites (serve, request-smuggling, node-http, 55 vendored Node keep-alive/pipeline tests) still pass.
…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>
|
Closing as superseded. #33005 and #42986 are on I checked the five first-request shapes from this PR against a debug build of The tests in this PR expect a response to |
Problem
Bun.serveleaves a connection open that it had already marked for close when a well-formed HTTP/1.1 request is pipelined behind the request that marked it, in the same TCP segment. Affected first requests:GET / HTTP/1.0(with or without aConnectionheader), an HTTP/1.1 request withConnection: close, and an HTTP/1.1 request whoseResponsecarriesConnection: close. A request sent on that connection later is still served.HttpParser.hused to latchisAncientHTTPfor the rest of the recv buffer, so the pipelined HTTP/1.1 request was itself treated as HTTP/1.0 and re-marked the connection for close. That PR made the flag per-request (needed for its Transfer-Encoding check), which exposed the bug below. The twoConnection: closeshapes were already broken the same way in released builds (checked on 1.3.14, where the three HTTP/1.0 shapes still close).HttpResponseData::resetResponseState()(packages/bun-uws/src/HttpResponseData.h) runs for every request dispatched on a connection (HttpContext.h, the request handler lambda) and clears the whole state word exceptHTTP_CONNECTION_SCOPED.HTTP_CONNECTION_CLOSEwas not in that set, so dispatching the pipelined request wiped the close mark set by the previous request, and the post-parseshouldCloseConnection()gate inHttpContext::onDatafound nothing to act on.Fix
HTTP_CONNECTION_CLOSEtoHTTP_CONNECTION_SCOPED, soresetResponseState()keeps it.Connection: closeinHttpContext.h;end(..., closeConnection)and close-delimited bodies inHttpResponse.h; aConnection: closeresponse header inNodeHTTP.cpp; node:http's deferredsocket.end()inJSNodeHTTPServerSocket.cpp), and nothing ever clears it on purpose: a connection that has been marked can not legitimately become persistent again. A fresh connection starts with a zeroed state word, so nothing leaks between connections.shouldCloseConnection()gates (onDatatail,internalEnd,onWritable) close the socket once that response has drained. Whether the pipelined request should be dispatched at all (RFC 9112 9.6) is a separate question, tracked in Bun.serve: stop dispatching pipelined requests after Connection: close (RFC 9112 9.6) #33005 forBun.serveand node:http: do not answer a request pipelined behind a response that closes the connection #35054 for node:http'ssocket.end(); both compose with this change.test/js/bun/http/serve.test.ts, describea request pipelined behind a non-persistent request does not keep the connection alive. Five first-request shapes (HTTP/1.0, HTTP/1.0 +Connection: keep-alive, HTTP/1.0 +Connection: close, HTTP/1.1 +Connection: close, HTTP/1.1 answered withConnection: close) each followed by a pipelined HTTP/1.1 request; the client sends a probe request once it has the pipelined response and the test settles on whichever comes first, the server closing or the probe being answered. All five fail on current main withprobe answeredand pass with the fix; on 1.3.14 the three HTTP/1.0 shapes pass and the twoConnection: closeshapes fail; the keep-alive control (probe answered) passes everywhere.--rerun-each(150/150),serve.test.ts(288 pass; the 4 failures,requestIP v6, privileged port,#6583,/bun:infoloopback, fail identically on an unmodified build in this environment),request-smuggling.test.ts(87 pass),bun-server.test.ts(3 failures, same environment-only set as on an unmodified build),bun-serve-headers/-file/-routes/-static,http-server-chunking,proxy-stress-protocol,serve-directory-routes,serve-direct-readable-streamand 8 HTTP regression files (396 pass),node-http.test.ts(1 failure,request via http proxy, environment-only), 6 node:http connection-handling files (52 pass), and 55 vendored Node http keep-alive / pipeline / close / client-error tests (all pass).Background
HttpResponseDataper socket and reuses it for every request on a keep-alive connection. Itsstateword mixes per-response bits (status written, Content-Length written, response pending, ...) with per-connection bits.resetResponseState()starts a new response by clearing the word down toHTTP_CONNECTION_SCOPED, the bits that describe the connection (parsing stopped, reads paused, peer FIN received, close when idle); that set is what this PR extends.fenceAndConsumePostPadded(HttpParser.h) loops over every request present in one recv buffer and dispatches each one through theHttpContext.hlambda.Bun.servesupports this only when the previous response completed synchronously (otherwise it closes the connection), which is why the test's handler returns aResponsedirectly.shouldCloseConnection()is consulted after the parse loop (HttpContext::onData), when a response ends (internalEnd) and when buffered output drains (onWritable), and each only acts onceHTTP_RESPONSE_PENDINGis clear and the socket has fully drained, so the response to the pipelined request is delivered before the FIN.Connection: keep-alive;Bun.servenever does, so it always marks such connections for close (HttpContext.h,isAncient()), independent of what the request'sConnectionheader says.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/bun/http/serve.test.ts