Conversation
… behind an async handler When a client sends two pipelined HTTP/1.1 requests in one TCP segment and the handler for the first responds asynchronously, the second request reaches uWS's per-request callback while HTTP_RESPONSE_PENDING is still set. uWS has a single HttpResponseData per socket, so it cannot dispatch the second request; previously it called us_socket_close(), which tore the connection down before any bytes of the first response reached the wire. Instead, mark the in-flight response for connection-close and let the parser discard the pipelined request. The first response is delivered in full and the socket closes once it is drained; the client retries the dropped request on a new connection (RFC 9112 9.3.2). The write offset reset is moved after the check so it no longer clobbers the in-flight response's offset. Affects both node:http and Bun.serve (they share HttpContext.h). Synchronous pipelining (handler responds before returning) is unchanged: markDone() clears HTTP_RESPONSE_PENDING before the next request is parsed.
Walkthrough
HTTP Pipelining Fix
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 5:11 PM PT - Jun 27th, 2026
❌ @robobun, your commit 6f6b9aa has 2 failures in
🧪 To try this PR locally: bunx bun-pr 32868That installs a local version of the PR into your bun-32868 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
On the two bot suggestions above: #32488: that PR implements full HTTP/1.1 pipelining for #31889: not fixed by this PR. In that scenario the first response's body is already fully written when the reused request arrives; this change still drops the reused request and closes once the deferred |
The client calling socket.end() once the first response arrives means the server always sends FIN in reply (uWS onEnd closes on half-close), so the serverClosed check could not distinguish a proactive server close from an echoed client FIN. Keep the substantive assertions: the first response reached the wire and the pipelined handler was never dispatched.
Addresses three review findings: When the in-flight response completes under backpressure, markDone() clears HTTP_RESPONSE_PENDING before the socket is closed; a third pipelined request arriving in that window would pass the PENDING check and be dispatched (and the assignment at state = HTTP_RESPONSE_PENDING would wipe HTTP_CONNECTION_CLOSE), misattributing its response to the request that was dropped. Extend the early return to also cover HTTP_CONNECTION_CLOSE so no further request is dispatched once one has been dropped on the socket. Move the per-request us_socket_timeout(s, 0) below the early return so a dropped request does not disarm the in-flight response's idle timer. Drop the separate-packet test: its ordering depends on event-loop phase timing rather than an explicit barrier, so it cannot observe whether the fix path was actually taken. The same-segment test (and the Bun.serve variant with expect(calls).toBe(1)) deterministically guard the HTTP_RESPONSE_PENDING branch. Mark test-http-pipeline-socket-parser-typeerror.js as expected-fail: it requires full async pipelining (second handler dispatched while the first is pending). It previously passed only because the pipelined request triggered a hard socket close and the process exited before the test logic ran.
getHeaders writes isConnectRequest = true through the bool& before requestHandler runs, so a dropped pipelined CONNECT would leave the per-socket flag set and route later bytes on the socket through the CONNECT-tunnel path. Clear it in the early-return so the in-flight response's handling is unaffected.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/bun/http/serve.test.ts`:
- Around line 2401-2416: The test around the net.connect flow in serve.test.ts
is too weak because socket.end() lets the close event come from the client FIN
instead of proving the server drained and closed the connection. Update the
assertion logic in this test to wait for and verify the server’s own Connection:
close / socket shutdown behavior after the FIRST response, using the existing
wire capture and the net socket event handlers so the test fails if the server
keeps the connection reusable.
🪄 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: 51d8530f-3eb4-4d5f-ae23-979bb19e045f
📒 Files selected for processing (4)
packages/bun-uws/src/HttpContext.htest/expectations.txttest/js/bun/http/serve.test.tstest/js/node/http/node-http.test.ts
|
CI status on 6f6b9aa (build #65651): the only red is unrelated to this change.
The Not pushing another retrigger; ready for review when convenient, or close in favour of #32488 if that is landing soon. |
|
Pushed an alternative approach on Summary of the difference:
Three tests in Happy to fold this into this PR or open a separate one, whichever is preferred. |
|
Thanks, I read through
So the shape I'd suggest: this PR stays the minimal "do not lose the in-flight response" fix (small, reviewed, all threads resolved), and the buffered-re-dispatch work is a good intermediate if a maintainer wants it before #32488, but as its own PR so it gets its own review. If #32488 is landing soon both are moot and this can just close. If a maintainer would rather have the buffered approach in this PR I am happy to do that instead. |
|
Opened #33664 with a Bun.serve-only variant that buffers the pipelined request(s) and re-dispatches from |
…d it HttpContext::onData pauses the socket while request bytes are parked behind a pending response. If that response turns out to be a WebSocket upgrade, upgrade() destructs the HttpResponseData (and the parked bytes with it, as for bytes trailing a synchronous upgrade) but us_socket_adopt carries the paused flag over, so the WebSocket sent its 101 and then never read a frame. Drop the parked bytes and resume before internalEnd(), so markDone() does not arm a replay dispatch for them either. Tests folded in from the earlier pipelining PRs (#33664, #35036, #32868) for the cases bun-serve-pipelining.test.ts did not cover yet: a Bun.file() body from the handler and a Bun.file() route over each transport, a held request that carries a body with a Connection: close request behind it, held bytes that are a parse error, and the upgrade case above.
|
Closing: both halves of this PR are covered elsewhere now, and this branch conflicts with main.
Any further work on the Bun.serve side should go to #38128. |
…d it HttpContext::onData pauses the socket while request bytes are parked behind a pending response. If that response turns out to be a WebSocket upgrade, upgrade() destructs the HttpResponseData (and the parked bytes with it, as for bytes trailing a synchronous upgrade) but us_socket_adopt carries the paused flag over, so the WebSocket sent its 101 and then never read a frame. Drop the parked bytes and resume before internalEnd(), so markDone() does not arm a replay dispatch for them either. Tests folded in from the earlier pipelining PRs (#33664, #35036, #32868) for the cases bun-serve-pipelining.test.ts did not cover yet: a Bun.file() body from the handler and a Bun.file() route over each transport, a held request that carries a body with a Connection: close request behind it, held bytes that are a parse error, and the upgrade case above.
…d it HttpContext::onData pauses the socket while request bytes are parked behind a pending response. If that response turns out to be a WebSocket upgrade, upgrade() destructs the HttpResponseData (and the parked bytes with it, as for bytes trailing a synchronous upgrade) but us_socket_adopt carries the paused flag over, so the WebSocket sent its 101 and then never read a frame. Drop the parked bytes and resume before internalEnd(), so markDone() does not arm a replay dispatch for them either. Tests folded in from the earlier pipelining PRs (#33664, #35036, #32868) for the cases bun-serve-pipelining.test.ts did not cover yet: a Bun.file() body from the handler and a Bun.file() route over each transport, a held request that carries a body with a Connection: close request behind it, held bytes that are a parse error, and the upgrade case above.
…d it HttpContext::onData pauses the socket while request bytes are parked behind a pending response. If that response turns out to be a WebSocket upgrade, upgrade() destructs the HttpResponseData (and the parked bytes with it, as for bytes trailing a synchronous upgrade) but us_socket_adopt carries the paused flag over, so the WebSocket sent its 101 and then never read a frame. Drop the parked bytes and resume before internalEnd(), so markDone() does not arm a replay dispatch for them either. Tests folded in from the earlier pipelining PRs (#33664, #35036, #32868) for the cases bun-serve-pipelining.test.ts did not cover yet: a Bun.file() body from the handler and a Bun.file() route over each transport, a held request that carries a body with a Connection: close request behind it, held bytes that are a parse error, and the upgrade case above.
What
A raw TCP client pipelines two HTTP/1.1 requests back to back:
The handler for
/aresponds asynchronously:Before: the connection is destroyed with zero bytes written. Node.js serves both responses in order.
After:
/a's full response is delivered and the socket closes; the client retries/bon a new connection.Cause
HttpContext<SSL>::onData's per-request callback checksHTTP_RESPONSE_PENDING:uWS has one
HttpResponseDataper socket, so when/bis parsed while/a's handler is still pending the flag is set and the socket is closed outright. The socket is still corked at that point, so nothing reaches the wire.Fix
Set
HTTP_CONNECTION_CLOSEand return the live socket instead of closing. The parser discards the pipelined request's body (itsinStreamcallback was already cleared after the first request's body fin), and when the first handler eventually callsres.end()the normal close-after-drain path ininternalEnd/onWritableshuts the socket down. Theoffset = 0reset moves below the check so it no longer clobbers the in-flight response's write offset.This is not full async pipelining support; the dropped request is not queued. Per RFC 9112 9.3.2 a pipelining client must be prepared to retry unanswered requests when the server closes, so delivering the first response and closing is a conforming degradation. Synchronous pipelining (handler responds before returning, so
markDone()clearsHTTP_RESPONSE_PENDINGbefore the next request is parsed) is unchanged.Both
node:httpandBun.serveshareHttpContext.hand both benefit.Verification
New tests in
test/js/node/http/node-http.test.ts(same-segment and separate-packet pipelined request behind an async handler) andtest/js/bun/http/serve.test.ts(Bun.serve variant). On the unfixed build:Also ran:
node-http.test.ts(full),serve.test.ts(full),hspec.test.ts,http-server-chunking.test.ts,request-smuggling.test.ts, and the vendoredtest-http-get-pipeline-problem.js/test-http-keep-alive-pipeline-max-requests.js/test-http-keep-alive-drop-requests.js/test-http-1.0-keep-alive.js. No new failures.