Skip to content

node:http: apply the socket timeout while the body of an Upgrade request arrives - #43617

Open
robobun wants to merge 4 commits into
mainfrom
robobun/25014766/upgrade-body-socket-timeout
Open

robobun wants to merge 4 commits into
mainfrom
robobun/25014766/upgrade-body-socket-timeout

Conversation

@robobun

@robobun robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The body of a node:http Upgrade request arrives through req after 'upgrade'. If it stalls, socket.setTimeout() and server.timeout do nothing. The socket stays open until requestTimeout (default 300 s). Node v26.3.0 emits 'timeout' on req and the server, or destroys the socket.
  • The hand-off of the socket to the 'upgrade' listener (src/js/node/_http_server.ts:975) always removes onNodeHTTPServerSocketTimeout. Node's onParserExecuteCommon removes socketOnTimeout and frees the parser only once req.complete is true.

Fix

  • With a body, the hand-off keeps the 'timeout' listener and the parser stand-in (socket.parser). releaseSocketForHandoff removes both when the message completes. CONNECT and a body-less Upgrade do not change.
  • Bun sees the end of a body only while it reads the connection. So the release also runs when the listener pauses or ends the socket, or pauses the request. A timeout does nothing when only the native handle has the end of the body, or ws owns the connection. Those states behave as on main. Without the guards it destroyed live tunnels (Notes).
  • Verified: test/js/node/http/node-http-server-timeouts.test.ts (17 new tests, main fails 5). Also node-http.test.ts, the ws suites, upstream test-http-upgrade-*.
  • Self-reviewed: 17 concerns raised, 16 addressed. Not done: a stack on node:http: the server follows Node (request body, framing, response finish, lifecycle) #43557 (Notes).

Background

  • server.timeout arms socket.setTimeout() per connection. Node's socketOnTimeout forwards the socket's 'timeout' to the request (while !req.complete), the response and the server. With no listener it destroys the socket. onNodeHTTPServerSocketTimeout is Bun's copy.
  • An Upgrade has no ServerResponse, so only req and the server hear 'timeout'.
  • req.pause() and socket.pause() stop reads of the connection in Bun, not in Node.
Notes

Provenance

A differential probe against Node v26.3.0 found this during the review of #43456. No user reported it. It is flow 8 of #43455. The notes of #43337 and #43561 name the early listener detach as separate work.

Repro

An Upgrade POST with Content-Length: 100 and 10 body bytes. The 'upgrade' listener calls req.socket.setTimeout(200) and adds its own socket 'timeout' listener. listen also adds a 'timeout' listener to req.

listen
  node v26.3.0:  ["upgrade complete=false","req timeout complete=false","socket timeout"]
  bun canary:    ["upgrade complete=false","socket timeout","nothing closed the socket after 1500 ms"]
  this branch:   ["upgrade complete=false","req timeout complete=false","socket timeout"]
nolisten
  node v26.3.0:  ["upgrade complete=false","socket timeout","socket closed by server"]
  bun canary:    ["upgrade complete=false","socket timeout","nothing closed the socket after 1500 ms"]
  this branch:   ["upgrade complete=false","socket timeout","socket closed by server"]

More probes that give the same result as Node on this branch and differ on the canary: server.setTimeout(200, cb) (the server gets 'timeout'), req.setTimeout(200, cb), a chunked body that stalls inside a chunk, an https server, and an Upgrade on a kept-alive connection after a normal request. While the body arrives, req.socket.listenerCount("timeout") is 1 and req.socket.parser is set. After it, both are released, as in Node. A timeout that fires after the body is complete does nothing, read or unread.

Why the release has guards

The first version released only at the end of the message. Two rounds of self-review found flows where it destroyed a live tunnel, and the review of this PR found one more (the last row). In each flow the client sent the whole body, Node and the canary keep the tunnel open at server.timeout, and the version without the guard destroyed it. The cause is always the same: req.complete in Bun follows what JS has seen, not the wire.

Flow Why JS does not see the end of the body Guard
The listener calls req.pause() (also pipe backpressure) The 'pause' handler stops reads of the connection (item 3 of #43455, #43557) release in that handler
The listener calls socket.pause() The same. Node pauses only its UpgradeStream wrapper release in pause()
shouldUpgradeCallback pauses the request The same, before the hand-off release at the hand-off
The listener ends the socket, the client keeps its side open uWS drops all bytes that arrive after the shutdown release in _final
A chunked body fills the request buffer and the same read holds the last chunk The native handle parks the rest of the read until JS reads req again (#43466) the handler checks handle.hasBody
req._dump() while a read is pending _dump clears handle.ondata (flow 7 of #43455) the same check, the native state is done
for await (const chunk of req) break, or pipeline(req, dest) with a destination that fails _destroy releases the native handle (flow 9 of #43455) the handler checks for a missing handle
ws upgrades a handshake that declares a body The connection is a WebSocket, the body never ends the handler checks handle.upgraded
The listener runs an http client request over the socket while the body arrives That request puts its own parser on socket.parser, and the deferred release freed it the deferred release frees only the stand-in from the hand-off

The event-time releases sit exactly where Bun stops reading at the request of the listener. The states that cannot go back are checked when the timeout fires. So the handler acts only while Bun reads the body and nothing is parked, and in that state req.complete follows the wire. A stalled consumer whose request buffer is full stops reads in Node too (readStop), and Node destroys that socket at the timeout, so this branch does the same.

The nine "tunnel survives" tests and the parser test pin these flows, and each fails without its guard. The ws flow has no test: a debug build aborts with assertion failed: !self.body_read_ref.get().has when ws upgrades a request that declares a body (#43408, #43427). A probe shows the WebSocket alive past server.timeout on this branch.

Two batteries ran against the final version, with every byte of the body sent and server.timeout set. In 69 flow-control scenarios the tunnel outcome is the same as in Node, except for three rows. Two differ in the other direction: Node reads 64 KiB at a time and stops at the high water mark, Bun reads the whole 100 KB body at once and completes it. One is the corked socket below, the same on the canary. In 50 ways to abandon the request, the events and the tunnel outcome are the same as on the canary. The timeout in these runs was 1000 to 1500 ms: with 300 ms, the stalls of a debug build are longer than the timeout.

The first review proposed to stack this change on #43557 and on a fix for flow 9. I did not do that. The guards make the change independent of both, and the behaviour in those states is the behaviour of main. When #43557 removes the 'pause' handler, its guard goes with it.

Differences that stay

  • After a pause, the socket timeout no longer applies to the rest of that body (as on main). requestTimeout still does. The same holds after _dump(), after a destroy that keeps the socket, and after socket.end().
  • When the whole body arrives in the same read as the head, Node sees req.complete === true at the hand-off and releases the socket before 'upgrade'. Bun learns it a tick later, so inside the listener listenerCount("timeout") is 1 and socket.parser is set.
  • Node keeps socketOnError on the raw socket while the body arrives, so a socket error also reaches 'clientError'. Bun gives the listener the raw socket, not an UpgradeStream, so the listener must see the error on the socket it received. test-http-upgrade-server-with-body-error.mjs depends on that. For the same reason the listener can call socket.setTimeout(0) on what it received and switch the timeout off. In Node it needs req.socket for that.
  • A timeout that destroys the socket in the middle of the body shows up on req as 'end' with complete === true (node:http: an Upgrade request whose body is cut short by a connection close emits 'end' with complete=true #43543, the same for every close of the connection there).

Found on the way, not part of this change

  • While the body of an Upgrade request is incomplete, a write to the upgrade socket that does not fit the kernel buffer never drains. A client that reads as fast as it can gets 2.6 MB of 8 MB. The canary does the same, Node delivers all of it. With this change and a server.timeout, the timeout then ends that connection.
  • An Upgrade request that is not the first request on a kept-alive connection gets a corked socket, so the listener's writes never reach the client (fix(node:http): uncork reused upgrade sockets #42610).
  • test-http-agent-keepalive, test-http-client-timeout-option and test-https-timeout fail on a debug build of main and pass on the release canary.

Merge order

#43441 moves the upgrade hand-off into emitUpgradeHandoff, and #43461 adds an else branch next to it. Both can take releaseSocketForHandoff as is: release at once without a body, leave kFinishUpgradeHandoff on the request with one.

Suites run with the debug build

node-http-server-timeouts, node-http, node-http-with-ws, node-http-req-socket-pause, node-http-server-abort-events, node-http-backpressure, node-http-transfer-encoding, node-http-connect, first_party/ws/ws.test.ts, ws-upgrade-events.test.ts, and 226 upstream test-http-* / test-https-* scripts (timeout, upgrade, connect, keepalive, server, pipeline, parser, incoming, socket, pause, abort, destroy, request, stream). The new tests passed 5 sequential runs and 8 parallel runs of the final version, and as many of each earlier version. In CI the file passed on Linux (x64, aarch64, musl, ASAN), Windows (x64, aarch64) and macOS (x64, aarch64).

Failures that are the same without this change: the three scripts above, the tests should run on bun case of node-http-connect.test.ts (needs more than its 5 s on a debug build), and the body_read_ref abort when the ws suites and node-http-connect run in one process. I left out 41 proxy scripts: the container sets HTTP_PROXY, and the 16 of them that I ran fail the same way on the canary.


[human-review] gate passed · iteration 1 · 4 files touched

fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-server-timeouts.test.ts
bun test v1.4.3 (367d939d9)

test/js/node/http/node-http-server-timeouts.test.ts:
(pass) node:http server timeout enforcement > headersTimeout closes a connection that never completes its request headers [801.49ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [451.81ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [292.30ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1362.37ms]
(pass) node:http server timeout enforcement > emits 'clientError' once per stalled request when the listener keeps the socket open [1064.15ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [327.36ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [569.53ms]
(pass) node:http s
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (428214a9e)

test/js/node/http/node-http-server-timeouts.test.ts:
(pass) node:http server timeout enforcement > headersTimeout closes a connection that never completes its request headers [257.78ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [353.42ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [203.86ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1204.42ms]
(pass) node:http server timeout enforcement > emits 'clientError' once per stalled request when the listener keeps the socket open [955.54ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [253.04ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [404.41ms]
(pass) node:http server timeout enforcement > a pipelined request that is still being received gets 'timeout' and can keep the socket [203.06ms]
(pass) node:http server timeout enforcement > wit
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/http/node-http-server-timeouts.test.ts
bun test v1.4.3 (367d939d9)

test/js/node/http/node-http-server-timeouts.test.ts:
(pass) node:http server timeout enforcement > headersTimeout closes a connection that never completes its request headers [840.78ms]
(pass) node:http server timeout enforcement > requestTimeout closes a connection that stalls mid-body [452.41ms]
(pass) node:http server timeout enforcement > server.setTimeout() fires the 'timeout' event for an inactive connection [315.44ms]
(pass) node:http server timeout enforcement > keepAliveTimeout closes an idle keep-alive connection after the response [1371.09ms]
(pass) node:http server timeout enforcement > emits 'clientError' once per stalled request when the listener keeps the socket open [1070.93ms]
(pass) node:http server timeout enforcement > headersTimeout answers 408 when there is no 'clientError' listener [313.97ms]
(pass) node:http server timeout enforcement > requestTimeout does not fire while a slow handler streams a response [550.05ms]
(pass) node:http s
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 737ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] gen JS modules (bundle-modules)
Preprocess modules (9696ms)
Bundle modules (38ms)
Postprocesss modules (41ms)
Bundle Functions (548ms)
Generate Code (27ms)

[10.36s] Bundled "src/js" for production
  2608 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/6] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-ebd5a6145bf7ad92-lto/lib/libJavaScriptCore.a(UnifiedSource-inspector-2.cpp.o at 102509102)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-ebd5a6145bf7ad92-lto/lib/libJavaScriptCore.a(UnifiedSource-inspector-2.cpp.o at 102509102)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triple
... (truncated)
diff hotspot
src/js/internal/http.ts                            |  12 +
 src/js/node/_http_incoming.ts                      |   5 +
 src/js/node/_http_server.ts                        |  36 ++-
 .../js/node/http/node-http-server-timeouts.test.ts | 253 +++++++++++++++++++++
 4 files changed, 301 insertions(+), 5 deletions(-)

gate history · 3 passed · 0 rejected · iteration 1

evidence per changed file
file                                                 reads  edits  tests
src/js/internal/http.ts                                  6      6     43
src/js/node/_http_incoming.ts                            2      3     42
src/js/node/_http_server.ts                              9     14     45
test/js/node/http/node-http-server-timeouts.test.ts      5      3     41

…ade request is complete

Node removes socketOnTimeout and frees the parser at the 'upgrade' hand-off
only when the request message is already complete. While a declared body
still arrives, the socket inactivity timeout emits 'timeout' on the request
and on the server, and destroys the socket when nothing listens.

The hand-off removed the 'timeout' listener in every case, so the socket
timeout never applied to a body that stalled. The listener and the parser
stand-in now stay until the message completes.

Bun sees the end of the body only while it reads the connection and the
request. So the hand-off becomes final at once when the 'upgrade' listener
pauses the request or the socket, or ends the socket. A timeout does nothing
when the native handle has the end of the body and JS does not, when the
request was dumped or destroyed, and after ws turned the connection into a
WebSocket. In those states the behavior is the same as before.
@robobun

robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:28 AM PT - Sep 20th, 2026

❌ @robobun, your commit 381ba4c has 1 failures in Build #118903 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 43617

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

bun-43617 --bun

@robobun

robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. This PR fixes flow 8 of #43455. It needs a maintainer to merge: the diff is green, and CI is red only from tests that this diff does not touch.

How to reproduce the bug:

  1. Save the script from flow 8 of node:http server: req.complete and request completion still differ from Node (flows 4, 8 and 9 remain) #43455 as upgrade-body-timeout.js. It sends an Upgrade POST with Content-Length: 100 and 10 body bytes. The 'upgrade' listener calls req.socket.setTimeout(200).
  2. Run node upgrade-body-timeout.js nolisten with Node v26.3.0. It prints ["upgrade complete=false","socket timeout","socket closed by server"].
  3. Run it with Bun 1.4.3-canary.1+367d939d9. It prints ["upgrade complete=false","socket timeout","nothing closed the socket after 1500 ms"].
  4. Run it with this branch. It prints the same line as Node. The listen argument adds a 'timeout' listener to req, and that listener now runs as in Node.

Test: bun bd test test/js/node/http/node-http-server-timeouts.test.ts. Five of the 17 new tests fail on main, and all 17 pass with this branch. The other 12 guard flows where the body is complete on the wire, or the listener owns the socket, and the timeout must leave the tunnel alone.

CI: the test file passes (27 pass, 0 fail) on all 11 lanes in the last two builds: Linux x64 and aarch64 (glibc, musl, ASAN), Windows x64 and aarch64, macOS x64 and aarch64. Each build has a different red test, and none of them uses node:http:

  • build 118903: test/bake/deinitialization.test.ts on alpine aarch64.
  • build 118898: test/cli/run/run_command.test.ts on debian aarch64 (SIGABRT in place of SIGINT), and test/js/bun/spawn/spawn.test.ts on debian x64-asan, which is red or retried in 31 of 45 recent builds of other PRs.

All three are reported for triage. express.text.test.ts needed a retry on macOS aarch64 in build 118903. I checked it, because express runs on node:http: with 8 runs in parallel on a debug build, its first test exceeds 5 s in 1 of 200 runs on main and in 2 of 200 runs on this branch.

@coderabbitai

coderabbitai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 85249560-6314-475a-b9ce-cde00396be04

📥 Commits

Reviewing files that changed from the base of the PR and between 78e03f3 and 381ba4c.

📒 Files selected for processing (3)
  • src/js/internal/http.ts
  • src/js/node/_http_incoming.ts
  • src/js/node/_http_server.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


Walkthrough

Changes

Upgrade request handoffs now complete when request bodies finish, reads pause, sockets finalize, or relevant timeouts occur. Parser and timeout ownership remain active until handoff completion. Tests cover Upgrade and CONNECT behavior.

Upgrade handoff completion

Layer / File(s) Summary
Handoff completion contract
src/js/internal/http.ts, src/js/node/_http_incoming.ts
A one-time handoff helper now runs on upgraded request EOF and pause paths.
Server handoff lifecycle
src/js/node/_http_server.ts
Upgrade and CONNECT paths coordinate body completion, timeout handling, parser release, socket pause, and finalization.
Timeout and tunnel validation
test/js/node/http/node-http-server-timeouts.test.ts
Tests cover incomplete and complete bodies, paused and delayed reads, tunnel survival, and bodyless handoffs.

Suggested reviewers: cirospaciari

Priority: ➖ Normal

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly describes the main change: applying socket timeouts while an Upgrade request body arrives.
Description check ✅ Passed The description explains the problem, implementation, behavior, edge cases, and verification results. It does not use the exact template headings, but it contains the required information for both sec…

Comment @coderabbitai help to get the list of available commands.

@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/js/node/_http_incoming.ts — nit: The new per-request slot is never declared in the IncomingMessage constructor, unlike most other symbol fields. kFinishUpgradeHandoff is only assigned lazily at src/js/node/_http_server.ts:984 for an Upgrade request with a body, so those objects get a late shape transition and finishUpgradeHandoff reads a missing property on every other upgrade EOF. Fix: initialise this[kFinishUpgradeHandoff] = undefined next to this[kAbortController] = null at src/js/node/_http_incoming.ts:94 (import the symbol from internal/http), so the field exists on every instance like the sibling symbol fields do.

    Extended reasoning...

    REVIEW.md asks that every instance field is declared with a default in the class body. src/js/node/_http_incoming.ts:78-94 initialises abortedSymbol, eofInProgress, kHeaders, kRawHeaders, kTrailers, kAbortController and the rest on every IncomingMessage. The diff adds a new symbol-keyed slot kFinishUpgradeHandoff (src/js/internal/http.ts:46) but only writes it at src/js/node/_http_server.ts:984, and only for an accepted Upgrade request that declares a body and is not paused. finishUpgradeHandoff (src/js/internal/http.ts:47-53) reads req[kFinishUpgradeHandoff] from emitEOFIncomingMessageOuter (src/js/internal/http.ts:165), the request 'pause' handler (src/js/node/_http_incoming.ts:58), socket.pause() and _final (src/js/node/_http_server.ts:1775, 1987); for CONNECT and body-less upgrades that lookup misses the own object and walks the prototype chain. Consequence is a hidden-class transition and a slower miss on an internal path, not a behavioural bug; nit only.

    Verification: nit. Trigger: any accepted Upgrade request that declares a body and is not paused in shouldUpgradeCallback. Mechanism verified: the IncomingMessage constructor (/home/claude/bun/src/js/node/_http_incoming.ts:77-94) initialises abortedSymbol, eofInProgress, kHeaders, kReqShouldKeepAlive, kRawHeaders, kHeaderSource, kHeadersCount, kTrailers, kTrailersCount, kAbortController on every instance, but…

  • 🟣 src/js/node/_http_server.ts — pre-existing, widened: after merging, an 'upgrade' listener that consumes the body of a stalled request has its socket destroyed at server.timeout, but req never reports the truncation. src/js/node/_http_server.ts:243 destroys the socket while the body is incomplete, and nothing aborts the request: #onClose at _http_server.ts:1678 only destroys _httpMessage.req, which is null for an Upgrade, and onDataIncomingMessage returns early on the abort event. Fix: when the connection closes with an incomplete Upgrade body, destroy socket[kUpgradeIncoming] with 'aborted' (ECONNRESET) like a normal request, so every close path (timeout, client reset, socket.destroy) reports it. The PR notes call this #43543; on the base branch the timeout never reached this path.

    Extended reasoning...

    Client sends an Upgrade POST with Content-Length: 100 and 10 body bytes, then stalls. server.timeout = 200 and no 'timeout' listener on req or server. The 'upgrade' listener reads req with on('data') and processes the body on 'end', or waits for 'aborted'/'error'. The timer fires: onNodeHTTPServerSocketTimeout at _http_server.ts:225-233 finds a handle, not upgraded, hasBody pending, so it falls through; no listener returns true, so this.destroy() at line 243 runs. Socket._destroy -> #closeHandle -> handle.close(); native handle_abort_or_timeout (NodeHTTPResponse.rs:1262-1299) runs onabort (onServerRequestEvent: socket already destroyed) and then on_data_or_aborted(b"", last=true, Abort), which first sets body_read_state = Done (NodeHTTPResponse.rs:1655) and calls req's ondata; onDataIncomingMessage (internal/http.ts:183-189) returns immediately for the abort event, so no push(null) and no destroy. #onClose (_http_server.ts:1636-1707) computes req from this._httpMessage, which the upgrade dispatch never assigned (assignSocket is skipped at line 918), so the request is not destroyed…

    Verification: nit (pre-existing misreport, widened by a new server-initiated path) — acknowledged in diff: the PR description's "Differences that stay" says "A timeout that destroys the socket in the middle of the body shows up on req as 'end' with complete === true (#43543, the same for every close of the connection there)"; that claim matches the code. Trigger: an accepted Upgrade request with a…

Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts
Comment thread test/js/node/http/node-http-server-timeouts.test.ts
Comment thread test/js/node/http/node-http-server-timeouts.test.ts Outdated
Comment thread src/js/node/_http_incoming.ts
Comment thread src/js/node/_http_server.ts Outdated
…e socket

The release of the socket after the body of an Upgrade request freed
whatever socket.parser held at that time. An http client request that the
'upgrade' listener runs over the socket puts its own parser there. The
deferred release now frees only the stand-in that the hand-off left.

IncomingMessage declares the kFinishUpgradeHandoff slot in its constructor,
like its other fields.

Tests: the chunked flow sends the head and the body in one write, so it
does not depend on the time between the two. A close of the socket fails a
'tunnel survives' test at once.
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
…ments

The three states in which JS cannot see the end of an Upgrade request's
body move into cannotSeeUpgradeBodyEnd(). Each comment that this change
adds is now one line.
@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to the two findings outside the diff in the review above.

The inline threads have their own replies. Two of them changed code: the deferred release now leaves a parser alone that the 'upgrade' listener put on the socket (78e03f3, with a test), and every comment that this PR adds under src/ is one line (428214a).

@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.

I re-reviewed the two follow-up commits and found no new bugs; the captured-parser check in releaseSocketForHandoff and the close-wired/asserted tunnel tests cover the earlier inline comments. Because the release now depends on several flow-control guards in the node:http hand-off, a maintainer look is still worthwhile.

What was reviewed:

  • releaseSocketForHandoff(socket, req, parser?): the bound closure captures socket.parser at hand-off, so a parser installed by the 'upgrade' listener survives the deferred release; the CONNECT and body-less paths pass no parser and release unconditionally, as before.
  • cannotSeeUpgradeBodyEnd in onNodeHTTPServerSocketTimeout: checked the three states (no handle, handle.upgraded, hasBody & done) against the flows the new "tunnel survives" matrix pins; the handler still falls through to Node's socketOnTimeout sequence otherwise.
  • The chunked "fills the request buffer" flow now sends head and body in one write, so it no longer depends on the head-to-body round trip beating the 200 ms timer.
Extended reasoning...

Overview

The PR changes when Bun's node:http server releases the socket's 'timeout' listener and parser shim for an Upgrade request that declares a body: instead of at hand-off, the release is deferred through a kFinishUpgradeHandoff closure on the request and run from message completion, request/socket pause, socket end, or the timeout handler itself when the body's end is no longer observable from JS. Files touched: src/js/internal/http.ts, src/js/node/_http_incoming.ts, src/js/node/_http_server.ts, plus about 250 lines of new tests in test/js/node/http/node-http-server-timeouts.test.ts. Since the prior review, commits 78e03f3 and 428214a added the captured-parser guard and its test, hardened the tunnel tests against hangs (close listeners resolve/reject, stream.writable asserted), combined the chunked head and body into one client write, and shortened comments.

Security risks

None specific to this change. The code runs in the JS builtin layer and does not parse untrusted bytes; the only new input-derived decisions are on handle.hasBody flags and Readable state. A hostile client can still only affect whether its own connection is destroyed at the inactivity timeout, which is the existing behaviour on main for non-upgrade requests.

Level of scrutiny

Moderate to high. The correctness of the change rests on a set of guards that encode where Bun stops reading the connection (pause handlers, _final, native done state, upgraded). Each is reasoned from Bun-specific flow-control behaviour that differs from Node, and the author notes one flow (ws upgrading a request with a body) that cannot be tested on a debug build. The bug hunt over the current head ran dry without new findings, and the earlier inline comments on the parser release and the test hangs are addressed in code; the _final-timing comment remains as an open thread for the author to weigh. That combination supports a human maintainer pass rather than an automated approve.

Other factors

The new tests follow the harness conventions (port 0, 127.0.0.1, Buffer.alloc fills, Promise.withResolvers, test.each matrices, failure events wired to the awaited promises) and pin listener counts and socket.parser state as observable compat surface. Comments were reduced to one-liners in the last commit, matching the repo's comment guidance. No pre-existing tests were weakened or removed.

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