Repository navigation
node:http: req.destroy() of an accepted CONNECT or Upgrade request writes no 200 OK into the tunnel - #45026
node:http: req.destroy() of an accepted CONNECT or Upgrade request writes no 200 OK into the tunnel#45026robobun wants to merge 3 commits into
Conversation
…nnel req.destroy() of an accepted CONNECT or Upgrade request called abort() on its native response. The response of a handoff is never written, so abort() ended it on the raw socket. The peer got "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" behind the reply of the listener, or as the answer to a tunnel that the listener refused. abort() now returns for a tunneled response. IncomingMessage._destroy destroys the socket right after, as Node.js does.
|
Updated 11:04 PM PT - Oct 11th, 2026
❌ @robobun, your commit deea32a has 2 failures in
🧪 To try this PR locally: bunx bun-pr 45026That installs a local version of the PR into your bun-45026 --bun |
|
Status: ready for review. CI is green on deea32a. How it was reproduced: the script under Notes in the description, run with |
There was a problem hiding this comment.
Beyond the inline findings, I also checked what the new early return in abort() skips for a tunneled handle: on_request_complete already short-circuits on TUNNELED (src/runtime/server/NodeHTTPResponse.rs:784) and finalize accepts a TUNNELED response without SOCKET_CLOSED (line 2665), so the skipped release_body_slot/mark_socket_closed bookkeeping does not strand the request in the no-body handoff case; and IncomingMessage._destroy / ServerResponse.prototype.destroy (src/js/node/_http_server.ts:4049) still call socket.destroy() after handle.abort(), so the connection still closes.
Extended reasoning...
One-line Rust guard in NodeHTTPResponse::abort plus nine new tests in test/js/node/http/node-http-connect.test.ts; no security-sensitive surface. Three inline findings remain (partial coverage of the write paths on the retained ServerResponse, the Upgrade-with-body variant untested, and the non-tunneled later-task 200 OK left as-is), so a human look is already signalled by them; this note only records the resource-release and socket-close checks that were ruled out.
Findings marked 🟡 are optional suggestions and need no follow-up push.
There was a problem hiding this comment.
LGTM — the new guard is a single flag test and the latest push adds the Upgrade-with-body row I asked for, so the one handoff where TUNNELED is set while uws still parses is now covered for both destroy timings.
Beyond the inline nit, I checked that skipping the rest of abort() for a tunneled response does not strand accounting: the socket teardown that IncomingMessage._destroy triggers still reaches set_closed/on_abort → mark_socket_closed → leave_pending, which releases the body slot and lets mark_request_as_done drop the pending-request ref, and every matrix row asserts server.close() resolves.
Extended reasoning...
The change adds one condition to NodeHTTPResponse::abort in src/runtime/server/NodeHTTPResponse.rs so a response flagged TUNNELED (CONNECT or Upgrade handoff) returns before end_without_body writes a 200 into the raw tunnel, plus a 12-case describe.each matrix and a custom-ServerResponse test in test/js/node/http/node-http-connect.test.ts. It touches node:http server native code but no auth, crypto, or input-parsing surface. The fix is three lines with clear intent, the tests assert exact bytes and that server.close() completes, and the second push addressed the with-body variant raised in the prior review; the remaining inline item is a nit about a vacuous optional-chaining call in one test. The plain-request later-task 200 raised earlier is pre-existing behavior the author explicitly scopes to a separate PR.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Still open from earlier reviews (1):
- 🔴
src/runtime/server/NodeHTTPResponse.rs:1688—Every plain request that user code destroys from a later task still answers the client with an empty 'HTTP/1.1 200 OK'…
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.
There was a problem hiding this comment.
I re-reviewed the latest push and found no new bugs; the two commits since my last pass address the earlier feedback (the Upgrade-with-body row in the describe.each matrix, and expect(response).toBeInstanceOf(Response) so the subclass test can no longer pass vacuously). Because the one-line change moves teardown of a TUNNELED response off abort() and onto the socket.destroy() close path in lifecycle-sensitive native code, a human look at src/runtime/server/NodeHTTPResponse.rs is still worthwhile.
What was reviewed:
- Traced
Flags::TUNNELEDfrommark_tunneled(set before the listener runs) throughshould_request_be_pending, the dispatch tail insrc/runtime/server/mod.rs:1540, and the Drop assertions — the new early return leaves no path where the flag is unset for a handoff. - Checked what the skipped cleanup (
clear_pending_pinned_write,release_body_slot,mark_socket_closed,on_request_complete) now depends on: both JS callers (_http_incoming.ts:377,_http_server.ts:4049) callsocket.destroy()right afterabort(), and the uws close reachesmark_socket_closed/mark_request_as_done_if_necessary; the matrix'sserver.close()resolving asserts the pending-request accounting is released, including the body-still-arriving row. - The remaining open thread (plain, non-handoff request destroyed from a later task) is pre-existing behavior the description scopes to #39366, not something this diff introduces.
Extended reasoning...
The diff adds one condition to NodeHTTPResponse::abort() in src/runtime/server/NodeHTTPResponse.rs so a TUNNELED (CONNECT/Upgrade handoff) response returns before resume_socket/mark_socket_closed/end_without_body/on_request_complete, plus 13 tests in test/js/node/http/node-http-connect.test.ts covering the connect/upgrade/upgrade-with-body x reply/no-reply x sync/later matrix and a custom-ServerResponse variant. It touches no auth, crypto, or injection surface; the sensitive part is refcount and pending-request lifecycle in native server code, where the skipped cleanup now relies on the subsequent socket.destroy() close path. The two commits since the prior review added the with-body matrix row and a precondition assertion, addressing earlier inline feedback, and no CODEOWNER covers the changed files. Defer rather than approve because the lifecycle-sensitive native area and the still-open plain-request thread warrant a maintainer's confirmation that scoping the fix to handoffs is the intended split from #39366.
Problem
req.destroy()on a request that a'connect'or'upgrade'listener took over makes Bun writeHTTP/1.1 200 OK\r\nConnection: close\r\n\r\ninto the tunnel, behind the reply of the listener. A listener that refuses a CONNECT this way in a later task answers it with that 200.NodeHTTPResponse::abort(src/runtime/server/NodeHTTPResponse.rs:1679). It ends the native response withend_without_body(true), and a handed-off request has no response. Node writes nothing and destroys the socket.Fix
abort()returns for a response with theTUNNELEDflag.upgradeToTunnel()sets the flag before the listener runs.IncomingMessage._destroydestroys the socket right afterabort().test/js/node/http/node-http-connect.test.ts(13 new tests, 9 fail on main). Alsonode-http.test.ts,node-http-server-abort-eventsandws.test.ts.Background
TUNNELEDmarks a response that is never written.req.upgradeinIncomingMessage._destroy.ServerResponse.prototype.destroycallsabort()too, and user code can writereq.upgrade.Downsides
abort()call.abort()grows by 5 bytes, and.textkeeps its size.200 OK(node:http: send nothing on destroy() before the headers go out, and a 500 (not an empty 200) for a throwing listener #39366).Notes
Repro. Run with
<connect|upgrade> <sync|later> <reply|refuse>.extrais what the client gets besides the reply of the listener.6c1eb067e3200 OK200 OK200 OK200 OK200 OK200 OK200 OK200 OK200 OK200 OKWhy
syncdiffers between 1.4.2 and main. Inside the listener the socket is corked. Until #43557 the close threw the cork buffer away together with the 200. Since #43557 a response that ends is sent at once.The matrix also runs with an Upgrade request whose body still arrives (2 of 5 bytes). There main writes the 200 in the two later-task cases.
The last test. Bun constructs a
ServerResponsefor an Upgrade request before it knows that the request is a handoff. A customServerResponseclass can keep that object, and itsdestroy()reaches the sameabort(). Node constructs no response there.Relation to #39366. That pull request stops
abort()from writing for every destroyed request. It conflicts with main. This change is the handoff case alone. When #39366 lands, the flag test here has no effect on the bytes, and the tests stay valid.Size. Release builds of main
6c1eb067e3with and without the change, linux x64. The.textsection is 65,818,853 bytes in both, and the stripped binary is 89,318,984 bytes in both.Suites. Debug build:
node-http-connect,node-http-upgrade-body,node-http-server-abort-events,node-http-with-ws,node-http-transfer-encoding,node-http-req-socket-pause,node-http-displaced-response,node-http-server-close-drain,node-http.test.tsandws.test.ts.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/node/http/node-http-connect.test.ts