Skip to content

node:http: hand a pipelined Upgrade request to 'upgrade' and keep its writes behind the responses ahead - #43441

Open
robobun wants to merge 13 commits into
robobun/8c4fb85c/pipelined-connect-tunnelfrom
robobun/a4e827de/pipelined-upgrade-handoff
Open

robobun wants to merge 13 commits into
robobun/8c4fb85c/pipelined-connect-tunnelfrom
robobun/a4e827de/pipelined-upgrade-handoff

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #43376 (the CONNECT half). The diff here is relative to that branch. Fixes #43420.

Problem

  • An Upgrade request that arrives while an earlier response on the same connection is still in flight goes to 'request' with req.upgrade === false. shouldUpgradeCallback never runs and 'upgrade' is never emitted. Node v26.3.0 runs the callback and emits 'upgrade' at once.
  • Cause: the !isPipelined gate in the dispatcher (src/js/node/_http_server.ts:889 on the base). The gate exists because the builtin ws answers through the socket's current response, which behind a pipeline is the response in flight.
  • A tunnel socket wrote straight to the connection (us_socket_write), past the uWS send buffer. Behind a large response its bytes landed inside that response's body and its FIN was lost. node:http: emit 'connect' for a CONNECT that is pipelined behind a pending response #43376 documents this race for CONNECT.

Fix

  • shouldUpgradeCallback runs for every Upgrade request. When it accepts, the connection enters tunnel mode and the socket goes to 'upgrade' at once, like Node. The listener can end the response ahead from inside 'upgrade', as the script in the issue does.
  • The socket parks its _write/_final callback (kPendingHandoff) until advanceResponsePipeline reaches this request, so the 101 follows the responses ahead on the wire. Node writes it first. A pipelined CONNECT (node:http: emit 'connect' for a CONNECT that is pipelined behind a pending response #43376) uses the same queue, with a test for the new order.
  • Natively, a tunnel's bytes wait in its stream buffer while the connection still owes HTTP output (uWS send buffer, or a zero-copy write in progress). HttpContext::onWritable flushes them once that is done. This applies to every tunnel, pipelined or not.
  • The builtin ws adopts the socket through kOnHandoffActive, and the dispatch tail keeps the upgrade context of a tunneled request (src/runtime/server/mod.rs:1501).
  • Verified: test/js/node/http/node-http.test.ts (12 new tests), node-http-connect.test.ts (1 new), node-http-with-ws.test.ts (1 new). All fail on the base. Notes below.

Background

  • Pipelining: a request arrives before the previous response is complete. The dispatcher marks it isPipelined, queues its response, and advanceResponsePipeline hands the socket to the next queued response when the current one finishes.
  • Tunnel mode (upgradeToTunnel): the parser stops reading HTTP after this request and passes later bytes to the JS socket unparsed. kHandoffResponse (node:http: emit 'connect' for a CONNECT that is pipelined behind a pending response #43376) is the native response of the handed-off request.
  • Writable issues one _write at a time. Holding its callback holds every later write and the _final behind them.
Notes

Shape. A review of the issue asked for the emit at dispatch with the writes held back, instead of an emit deferred until the responses ahead finish. A deferred emit hangs a listener that ends the response ahead from inside 'upgrade': the script in the issue and Node's test-http-pipeline-socket-parser-typeerror.js both do that.

Wire order. For GET /first followed by an Upgrade in one write, with /first ended from the 'upgrade' listener: Node v26.3.0 sends 101, then 200. This branch sends 200, then 101. The events and req.upgrade match Node.

Cork. The dispatcher corks the socket after each request and nothing uncorked it for a tunnel (#43342, fix in #35664). A listener that wrote the 101 and kept the tunnel open never got it onto the wire. The hand-off now uncorks the socket. #35664 stays useful for the 'request' path.

Second review round. A tunnel's deferred FIN now holds the Writable's _final callback until the native drain sent it. The ws adoption runs on a fresh turn (setImmediate), never inside the native writable callback of the response ahead, because HttpResponse::upgrade replaces the socket. A socket.end() from the listener keeps its place behind the responses queued ahead.

Third review round. A response ahead with an explicit Connection: close closed the connection natively at its end, before the queued hand-off could write. shouldCloseConnection() now leaves that to the tunnel's own socket.end() while a hand-off is queued. A destroyed socket also fails a write callback that waits for a native drain.

Fourth review round. A queued Upgrade with a body no longer holds the connection's reads while the response ahead drains, so the listener can read its body. After a Connection: close response ahead, the hand-off socket is destroyed once its FIN is out, like Node's destroySoon().

The upgrade context. Without the change in mod.rs, the dispatch tail saw no pending response (the listener had ended it) and discarded the context with maybe_stop_reading_body. server.upgrade() then returned false and ws answered 500.

Packaging. The self-review asked to fold the hand-off queue into #43376 so both tunnels land with one ordering. #43376 is a separate PR of the same author. The stack can be merged as one unit, or #43376 first and this PR after it.

Found next to this, not fixed here. markDone() (packages/bun-uws/src/HttpResponseData.h:52) clears the connection's inStream when a response ends. A pipelined request whose body is still arriving then loses the rest of its body, and its 'end' never fires. This is the same for a pipelined POST on main. An Upgrade with a body behind a response that ends mid-body now reaches 'upgrade' and takes the same path.

Related open PRs. #43413 sets req.upgrade before shouldUpgradeCallback runs. #43176 also edits completeUpgrade() in ws.js (replays early frames). Whichever lands second needs a small rebase there.

Suites run with the debug build. node-http.test.ts (170 pass), node-http-with-ws.test.ts, node-http-connect.test.ts, node-http-server-timeouts, node-http-req-socket-pause, node-http-backpressure, node-http-server-abort-events, node-http-server-socket-end-drain, first_party/ws/ws.test.ts, ws-proxy.test.ts, and 54 test/js/node/test/parallel/test-http-* files for upgrade, CONNECT, pipelining, keep-alive, pause and abort. On the shared machine (load average above 250) the 64 MB CONNECT > should handle backpressure case takes 6 s under the debug build and passes with a longer limit.

… writes behind the responses ahead

An Upgrade request that arrives while an earlier response on the same
connection is still in flight was dispatched to 'request' with
req.upgrade === false. shouldUpgradeCallback now runs at dispatch for
every Upgrade request. When it accepts, the connection enters tunnel
mode at once and the socket goes to 'upgrade' at once, like Node.

What the listener writes to the socket waits until the pipeline reaches
this request, so the responses ahead keep their place on the wire. A
pipelined CONNECT uses the same queue. The builtin ws adopts the socket
through that queue as well, since the native upgrade takes the
connection over, and the request keeps its WebSocket upgrade context
when the listener ends the response ahead during the dispatch.
Comment thread src/js/internal/http.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/thirdparty/ws.js Outdated
Comment thread src/runtime/server/mod.rs Outdated
@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:31 PM PT - Sep 19th, 2026

✅ @robobun, your commit dc2445606c4f090b4863b843d74df9369d3d1dab passed in Build #118532! 🎉


🧪   To try this PR locally:

bunx bun-pr 43441

That installs a local version of the PR into your bun-43441 executable, so you can run:

bun-43441 --bun

Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/runtime/server/mod.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 src/js/node/_http_server.ts — Behind a pipeline, an 'upgrade' listener that reads the socket of an Upgrade request carrying a body can get the request ahead's buffered body delivered as the Upgrade request's body, and the request ahead loses it. #resumeSocket at _http_server.ts:1817 takes handle.response, which for the pipelined hand-off added at :903-913 is still the response in flight, and :1825-1827 pushes whatever its resume() drains into kUpgradeIncoming. Fix: resolve the hand-off's own native response (this[kHandoffResponse] ?? handle?.response, as :2023 and :1622 already do) in #resumeSocket and in pause() at :2007, so pause/resume and the drained bytes target the handed-off request on every path. [also at: src/js/node/_http_server.ts:1817 - Behind a pipeline, reading the socket handed to 'upgrade' for an Upgrade request that carries a body can push the unread body tail of the request ahead into the Upgrade request's stream.]

    Extended reasoning...

    The base never reached this path: a pipelined Upgrade went to 'request' (the !isPipelined gate), and for a non-pipelined Upgrade handle.response is the Upgrade's own native response. Trigger: POST /first with a body whose handler calls req.pause() before the body is consumed (node-http-connect.test.ts:753-766 does exactly this for the CONNECT sibling), or leaves a body above the readable high-water mark unread (readStop -> handle.pause()), followed in the same write by GET /ws with Connection: Upgrade, Upgrade: foo and a Content-Length body. The native side buffers /first's body in buffered_request_body_data_during_pause and sets IS_DATA_BUFFERED_DURING_PAUSE_LAST at its fin (NodeHTTPResponse.rs:658-664), then parses the Upgrade head and dispatches it pipelined. _http_server.ts:903-913 runs upgradeToTunnel(true, handle) (HTTP_NODE_TUNNEL_AFTER_BODY only, isConnectRequest stays false: JSNodeHTTPServerSocket.cpp:114-119), then…

    Verification: normal — triggered when a pipelined Upgrade request that carries a body (Content-Length/Transfer-Encoding, so hasBody is true) arrives behind an in-flight request whose body bytes are parked natively (its handler called req.pause(), or a full push readStop'd the socket), and the raw 'upgrade' listener reads the socket. Mechanism verified in the code: - The new pipelined branch at… | normal —…

Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/thirdparty/ws.js
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/thirdparty/ws.js
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (9):

  • 🔴 src/js/node/_http_server.ts:912 — A raw 'upgrade' listener behind a pipelined response never gets its 101 onto the wire unless it also ends or destroys t…
  • 🔴 src/js/node/_http_server.ts:1984 — An 'upgrade'/'connect' listener behind a pipelined response whose client disconnects early never gets its socket.write(…
  • 🔴 src/js/node/_http_server.ts:2610 — A client that pipelines an Upgrade or CONNECT behind a large response can receive the listener's 101 spliced into the m…
  • Also unresolved: 6 minor or pre-existing.

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

A CONNECT or Upgrade socket wrote straight to the connection. Behind a
response whose tail was still in the uWS send buffer or in a zero-copy
write, those bytes landed inside that response, and a socket.end() lost
its FIN. The tunnel's bytes now wait in its stream buffer while the
connection owes HTTP output, and HttpContext::onWritable flushes them
once the responses ahead are done.

Also from the review of the pipelined hand-off: uncork the socket at the
hand-off, fail parked write callbacks when the socket is destroyed, let
pause/resume target the handed-off request's response, recheck the
WebSocketServer state in the deferred ws upgrade, and build no
ServerResponse for a CONNECT that is not pipelined.
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.h Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread packages/bun-uws/src/HttpContext.h
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp Outdated
…n queue order

From the review: the ws adoption runs on a fresh turn, since a native
callback on the stack may not outlive the socket it replaces. A socket
whose FIN waits for the bytes ahead of it completes 'finish' once the
FIN is out. A listener's socket.end() keeps its place behind every
queued response, and a response that closes the connection releases the
hand-off before the FIN. Failed parked callbacks run on the next tick.
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp Outdated
@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

This PR and my branch fix the same bug, so I stop there and do not open a second PR.

Branch: robobun/348af5a5/pipelined-upgrade, also stacked on #43376. Summary of its diff: the same dispatcher change, but raw tunnel writes go out at once like Node, and only the builtin ws waits for the responses ahead.

I ran the tests of that branch against the head of this PR (beba491) with a debug build. Four results can be useful here.

  1. The body of a pipelined Upgrade does not complete under backpressure. Test: node-http.test.ts, "should read the rest of the Upgrade body after the pending response has drained". It times out on this head. Node v26.3.0 completes it. The dispatch of the Upgrade pauses the reads, because the response ahead has unsent bytes. queuedResponsesHoldReads() in JSNodeHTTPServerSocket.cpp then holds the reads while a response is queued and isConnectRequest is false. For an Upgrade with a body, isConnectRequest stays false until the body ends, and the body cannot end without a read. The branch also returns false there for HTTP_NODE_TUNNEL_AFTER_BODY.
  2. server.upgrade() has no native check. Test: node-http-with-ws.test.ts, "server.upgrade() should refuse its response while the response ahead is pending". It makes the call of the ws module (Symbol.for("::bunternal::")) from the 'upgrade' listener, without the wait. On this head the call returns true and uWS adopts the socket. The next res.write() of the response ahead then runs on the adopted socket and throws Stream already ended. The branch adds a check to on_upgrade in src/runtime/server/server_body.rs: when get_this_value() != object, the response does not own the connection, and the call returns false.
  3. A second WebSocketServer aborts a handshake that waits. Test: "should stay with its WebSocketServer when another one does not handle the path". Two servers with path share one http.Server. The server that does not match calls abortHandshake(). Its upgraded check is still false while the first server waits, so it writes the 400 and destroys the socket. Without pipelining, the first server has upgraded at that point and the second one returns.
  4. A second handleUpgrade() on the socket does not throw while the first one waits. Test: "should throw for a second handleUpgrade() on the socket while it waits". Without pipelining it throws the "called more than once" error.

One more observation that follows from the design of this PR: abortHandshake() writes through the parked socket. When the response ahead never ends, the 400 for an invalid handshake never goes out, and the socket stays open.

The tests are in the two test files on the branch. Take what helps.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
… close response ahead

A response with Connection: close closed the connection natively at its
end, before the hand-off queued behind it could write. The native close
gate now leaves that to the tunnel's own socket.end(). A destroyed socket
also fails the write callback that waits for a native drain.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp — A raw 'upgrade' listener behind a pipelined response cannot read the Upgrade request's body until the response ahead finishes, so a listener that waits for that body before ending the response ahead hangs; Node resumes reads once the transport drains. queuedResponsesHoldReads at JSNodeHTTPServerSocket.cpp:469 exempts only isConnectRequest, but an Upgrade with a body stays HTTP_NODE_TUNNEL_AFTER_BODY until its body fin, so onNodeHttpReadsResumable keeps the flood pause while the hand-off is still counted in nodeHttpQueuedPipelinedCount. Fix: use the same tunnel predicate as HttpContext.h:863 and :930 (isConnectRequest || HTTP_NODE_TUNNEL_AFTER_BODY) so a queued hand-off never holds reads.

    Extended reasoning...

    Trigger: the client pipelines GET /first and an Upgrade request carrying a body (Content-Length or chunked) whose body bytes arrive in a later packet than its head (Node's http client writes head and body separately), while /first's response still has unsent bytes in the uWS send buffer. The dispatch of the Upgrade takes the pipelined branch at packages/bun-uws/src/HttpContext.h:482-490: nodeHttpQueuedPipelinedCount++ and, because getBufferedAmount() > 0, HTTP_NODE_READS_PAUSED is set and the socket is paused. The JS dispatcher (src/js/node/_http_server.ts:897-903) calls socketHandle.upgradeToTunnel(hasBody=true, handle): upgradeToTunnelModeImpl at JSNodeHTTPServerSocket.cpp:114-119 only sets HTTP_NODE_TUNNEL_AFTER_BODY and returns; isConnectRequest stays false until the body fin (HttpContext.h:620-623). 'upgrade' is emitted at once. The listener does req.on('data')/req.on('end', () => first.end()). req's _read resumes via handle.resume() -> do_resume (NodeHTTPResponse.rs:1393) -> resume_socket -> Bun__NodeHTTP__onReadsResumable -> onNodeHttpReadsResumable…

    Verification: nit (mechanism verified; the trigger is a narrow chain of client and listener behaviour, and the stall is bounded by requestTimeout). Triggering condition: a client pipelines an Upgrade request that carries a body (Content-Length/chunked) behind a response that has kernel-level backpressure (uWS getBufferedAmount() > 0) when the Upgrade head is parsed, the body bytes arrive in a later packet,…

Comment thread src/js/node/_http_server.ts
…on: close response ahead

An Upgrade with a body is a tunnel before its body ends; a queued one
no longer keeps reads paused, so the listener can read the body while
the response ahead drains. After a response that closes the connection,
the hand-off socket is destroyed once its FIN is out, like Node's
destroySoon().
Comment thread src/js/node/_http_server.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/node/http/node-http.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 packages/bun-uws/src/HttpResponseData.h — pre-existing, widened: a pipelined request whose body is still arriving when the response ahead ends loses the rest of that body and never emits 'end', so the handler waiting on it hangs; the pipelined Upgrade-with-body this PR now hands to 'upgrade' (src/js/node/_http_server.ts:898) takes the same path. markDone at HttpResponseData.h:52 sets inStream = nullptr for the whole connection, although inStream was armed by the queued request's on_data (HttpResponse.h:977), and HttpContext.h:625 then discards every later body chunk. Fix: markDone must not clear an inStream/userData that belong to a queued pipelined request (or startPipelinedResponse / the tunnel switch must re-arm it), for POST and Upgrade bodies alike.

    Extended reasoning...

    Same drop for a plain pipelined POST on the base; new here is that the Upgrade case now reaches the 'upgrade' listener, which typically reads req and ends the response ahead itself.
    Client pipelines GET /first and an Upgrade (or POST) with Content-Length N behind it; the body spans several segments (slow upload).
    /first stays pending, so the second request is dispatched with HTTP_NODE_PIPELINED_DISPATCH. The dispatcher sets handle.ondata (src/js/node/_http_server.ts:845) -> set_on_data (NodeHTTPResponse.rs:2282) -> raw_response.on_data -> HttpResponse.h:977 inStream = on_data_shim, userData = this request.
    For the Upgrade, line 898 upgradeToTunnel(true) sets HTTP_NODE_TUNNEL_AFTER_BODY and 'upgrade' is emitted; the listener attaches req.on('data'). Each chunk pushed re-arms inStream through _read -> resume (NodeHTTPResponse.rs:1415).
    The response ahead ends from a timer or its own async work: res.end -> uws_res_end -> HttpResponse::end -> internalEnd -> markDone (HttpResponse.h:247 or 310) -> HttpResponseData.h:52 inStream = nullptr.
    The next body segment arrives:…

    Verification: pre-existing (widened in consumer, same route): triggered when a client pipelines a request with a body (POST, or now an Upgrade with Content-Length) behind an in-flight response and the response ahead ends while that body is still arriving in later segments. Mechanism verified in the code: - packages/bun-uws/src/HttpResponseData.h:46-52 — markDone() unconditionally sets inStream = nullptr…

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant