Skip to content

http: classify idle connections by request message state, not response end - #37889

Closed
robobun wants to merge 1 commit into
mainfrom
farm/3d4d6bdf/close-idle-between-requests
Closed

robobun wants to merge 1 commit into
mainfrom
farm/3d4d6bdf/close-idle-between-requests

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • closeIdleConnections() (node:http and Bun.serve, so also server.close() and graceful stop()) destroys a keep-alive connection whose response was sent while the request body is still arriving. Closing a socket with unread inbound data resets it, which can discard the response the client was about to read.
  • It also destroys a connection that has the head of its next request already buffered, and it keeps one whose body finished arriving after an early response, so close() waits on that connection until the client or the keep-alive timeout ends it.
  • Cause: bun judged "idle" from the response side (a response finished and no bytes since). Node judges it from the request side: between request messages, with any assigned response finished. The two disagree in both directions, per the table in the original.
  • Related: a Content-Length: 0 request whose head arrived split across reads never counted as complete, so the next requestTimeout sweep answered 408 Request Timeout on an idle keep-alive connection (reproduced on 1.4.0; node keeps it).

Fix

  • Each connection now carries a "between requests" bit: set when a request message (head and body) has been fully received, cleared as soon as the next head is parsed or partially buffered, false until the first request arrives. Idle is derived from it: between requests, no response in flight, no pipelined response queued. CONNECT/Upgrade tunnels get no further messages, so they are judged by response state alone, which keeps their behaviour as before.
  • The idle sweep, the close-when-idle mark and the node:http headers/request timeout sweep all read this one bit, so the three facts are each maintained in one place and every row of the table matches node by construction. The old per-socket flag is removed. The parser now completes a split-head Content-Length: 0 request in the same pass, as the no-body branch already did.
  • Behaviour change: res.end(); server.close() from inside a handler no longer closes the handler's own connection (the request is still being received), as in node.
  • Verification: a state matrix test that passes verbatim under node v26.3.0 and, without the fix, fails on exactly the three divergent rows plus the in-handler case; a timeouts test and a Bun.serve test that also fail without the fix. The existing http, serve and websocket suites and 50 vendored node tests were run; the only failures also fail on an unmodified build.

Background

  • On a keep-alive connection a request message is head plus body. A handler runs at headers-complete, so a response can finish while the body is still arriving, and a client can pipeline the next request behind the current one.
  • Node's ConnectionsList tracks whether a message is currently being received; closeIdleConnections() and the headersTimeout/requestTimeout sweep both read that state, and a connection that has never sent a request counts as busy.
  • Both node:http and Bun.serve sit on uWS in packages/bun-uws; one per-connection data block holds parser state, response-pending state and the pipelined-response count, so an idle definition built from those applies to both servers. Connections accepted by listen() take this native path; node:http: only treat HTTP/1 fallback connections that are between requests as idle #37717 covers the JS fallback path (server.emit("connection"), http2 allowHTTP1).
  • Graceful stop() marks busy connections close-when-idle, and the same idle definition decides when that mark fires.
  • A head that arrives split across reads is finished out of a fallback buffer, a different parser pass from the usual single-read one.
Original description

What

http.Server#closeIdleConnections() (and therefore server.close()), Bun.serve's server.closeIdleConnections() and the close-when-idle mark left by a graceful server.stop() all go through uWS App::closeIdle. It decided "idle" from a per-socket flag that meant "a response has finished and no bytes have arrived since". Node decides it from the request side: a connection is idle only between request messages (ConnectionsList::idle(), i.e. the last message completed and the next one has not started; a connection that has not sent its first request yet counts as busy), and only if the response assigned to it, if any, has finished.

The two definitions disagree in both directions. One real TCP connection per state, one closeIdleConnections() call, node v26.3.0 vs bun 1.4.0 / main:

connection state node bun before bun after
connected, nothing sent kept kept kept
partial request head kept kept kept
response sent, request body still arriving kept destroyed kept
request received, response not finished kept kept kept
exchange done, head of the next request already buffered kept destroyed kept
exchange done, nothing since destroyed destroyed destroyed
response sent early, body finished arriving afterwards destroyed kept destroyed
closeIdleConnections() called from inside the request handler handler's connection kept destroyed kept

Every kept connection still gets served afterwards (node and bun after), and close() afterwards takes all of them. The "body still arriving" case is the harmful one: closing a socket that still has unread inbound data resets it, which can discard the response the client was about to read. The "body finished afterwards" case leaves close() waiting on a connection that is idle by every definition until the client or the keep-alive timeout closes it.

This is the same divergence #37717 fixes for the JS fallback path (server.emit("connection"), http2 allowHTTP1); this PR is the native path for connections accepted by listen(). Once both are in, the two matrix tests can be folded into one parameterized over the transport.

Cause

AsyncSocketData::isIdle was set in markDone() (response end) and cleared at the top of onData (any bytes). Nothing re-armed it when a request message completed, and a response ending mid-message set it regardless. The request-side state node keys on already existed on the node:http ext block (lastMessageStartMs, stamped at accept / head parsed / partial head buffered and zeroed at message complete, used by the headersTimeout/requestTimeout sweep), it just was not what the idle sweep looked at.

Fix

  • HttpResponseData gets a betweenRequests bit, maintained at exactly the points the node:http timing already used, for both server personalities: the data handler's fin sets it (before the chunk is delivered, so a response completing from inside the delivery already sees the connection idle), a parsed request head clears it, a partial head left in the fallback buffer after a read clears it, and it starts out false (node registers the parser as active at setup, so a connection that never sends anything is headersTimeout's problem, not an idle one).
  • isIdle() is derived from it: betweenRequests && !HTTP_RESPONSE_PENDING && nodeHttpQueuedPipelinedCount == 0. App::closeIdle and the HTTP_CLOSE_WHEN_IDLE gate (shouldCloseConnection()) use it; the isIdle flag, its maintenance in markDone()/resetResponseState()/onData, the markDone(uwsRes) parameter that existed only to set it, and the dead WebSocketData write are gone. A connection that became a CONNECT/Upgrade tunnel has no further request messages, so it is judged by response state alone: node:http tunnels keep the response they were created from pending (never idle, as before), a Bun.serve CONNECT that was answered stays reclaimable (as before).
  • The node:http timeout sweep (isRequestTimedOut) reads betweenRequests instead of lastMessageStartMs == 0, so the idle sweep and the timeout sweep share one definition of "between requests", which is how node's ConnectionsList serves both checkConnections() and closeIdleConnections(). lastMessageStartMs is now purely the timestamp.
  • HttpParser: a Content-Length: 0 request whose head is completed out of the fallback buffer (head split across reads) never got its empty fin chunk: the fallback pass skips the body dispatch and the caller only streams a body while remainingStreamingBytes is non-zero. Without the fin such a connection would never return to betweenRequests; today the same gap leaves the request's timeout window open, so the next requestTimeout sweep answers 408 Request Timeout on an idle keep-alive connection (reproduced on 1.4.0, node keeps the connection). It now completes the message in that pass too, the same way the no-body branch next to it always has; the single-read path already delivered this chunk, so consumers see nothing new.

Why this is the right shape rather than patching the flag: "idle" is now computed from three facts that are each maintained in one place (message boundaries, response in flight, responses queued), and it is the definition node uses, so the rows above match by construction rather than by approximation. For Bun.serve the declared contract ("not currently sending a request or waiting for a response", #37074) was already this; the implementation now matches it, which also fixes the graceful stop() analogue of the last table row (it no longer waits for idleTimeout on a connection whose upload finished after the response).

Behaviour change worth knowing about: res.end(); server.close() from inside a request handler no longer closes that handler's own connection; the request is still being received at that point (the handler runs from headers-complete), so, exactly as in node, the connection is left to keepAliveTimeout or the client. The existing test suites did not depend on the old behaviour (see below). Unchanged: a response whose bytes are still draining keeps HTTP_RESPONSE_PENDING, so it is still kept where node would destroy it mid-flush; closeIdle's iteration over the group is untouched (its cached-next hazard is tracked separately).

Tests

  • test/js/node/http/node-http.test.ts: the matrix above (plus a Content-Length: 0 split-head row for the parser change), each state confirmed through something the server does after reaching it, each verdict read as a positive signal (a response, or the connection going away) so there are no timers; then close() over the kept ones. Plus the in-handler case and a test that 'upgrade'/'connect' tunnels survive the sweep. The same code passes verbatim under node v26.3.0. On the unfixed build the matrix fails on exactly the body/reused/bodydone rows and the in-handler test fails on its second request.
  • test/js/node/http/node-http-server-timeouts.test.ts: after a split-head Content-Length: 0 exchange, a second connection is left to be reaped by requestTimeout; once it has been, the first connection must still serve a request (unfixed: it has been answered 408 and closed by the same sweep).
  • test/js/bun/http/bun-server.test.ts: two early-answered uploads; closeIdleConnections() must close 0 (unfixed: 2), the first connection must still serve a request behind the rest of its upload, and a graceful stop() must resolve once the second upload's body arrives.

Also run against this build: the full node-http.test.ts, the rest of node-http-server-timeouts.test.ts, node-http-backpressure.test.ts, the rest of test/js/node/http/, bun-server.test.ts, serve.test.ts, websocket-server.test.ts, and 50 vendored node tests covering server-close-idle/close-all, every headers-timeout/request-timeout variant, keep-alive, pipelining, CONNECT and upgrade. The only failures are ones that fail identically on an unmodified build in this environment (the http proxy test, IPv6 requestIP, root-range port, /bun:info, #6583, localhost upload abort, and a handful of 5 s / 10 s debug-build timeouts that reproduce on an unmodified debug build; the node:http fuzz fixture's server.close() was timed at ~85 ms with this change).

…e end

The idle sweep behind node:http server.close()/closeIdleConnections(),
Bun.serve closeIdleConnections() and graceful stop() keyed off a per-socket
isIdle flag that markDone() set when a response finished and onData cleared
whenever bytes arrived. That destroyed connections still receiving a request
body or the head of a pipelined request, and never reclaimed a connection
whose body finished arriving after its response.

Replace the flag with HttpResponseData::isIdle(), derived from a
betweenRequests bit maintained at the points the node:http headers/request
timeout tracking already used (message complete sets it; a parsed or
partially buffered head clears it; connections start busy, like Node's
ConnectionsList), plus the existing response-pending and pipelined-queue
state. The timeout sweep reads the same bit instead of lastMessageStartMs == 0.

Also complete a Content-Length: 0 message whose head was finished out of the
fallback buffer: that pass skipped the body dispatch, so the empty fin chunk
was never delivered and such a connection never counted as between requests.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 397900d8-943d-496c-b64e-939d82d9a0c7

📥 Commits

Reviewing files that changed from the base of the PR and between 9a543cc and a091992.

📒 Files selected for processing (12)
  • packages/bun-uws/src/App.h
  • packages/bun-uws/src/AsyncSocketData.h
  • packages/bun-uws/src/HttpContext.h
  • packages/bun-uws/src/HttpParser.h
  • packages/bun-uws/src/HttpResponse.h
  • packages/bun-uws/src/HttpResponseData.h
  • packages/bun-uws/src/WebSocketData.h
  • src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
  • src/uws_sys/libuwsockets.cpp
  • test/js/bun/http/bun-server.test.ts
  • test/js/node/http/node-http-server-timeouts.test.ts
  • test/js/node/http/node-http.test.ts

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

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 PM PT - Aug 12th, 2026

❌ @robobun, your commit a091992 has 3 failures in Build #93397 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37889

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

bun-37889 --bun

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.0 and on an unmodified debug build of main with one raw TCP connection per state and a single server.closeIdleConnections() (node v26.3.0 as the reference): a connection whose response was sent while its request body is still arriving and a connection with the head of its next request already buffered were destroyed (node keeps both); a connection whose body finished arriving after its early response was kept (node destroys it). The same unfixed builds also answer 408 Request Timeout on an idle connection after a Content-Length: 0 request whose head arrived in two reads.

Fix and tests are in this PR; the new tests in node-http.test.ts, node-http-server-timeouts.test.ts and bun-server.test.ts fail on the unfixed builds on exactly those cases and pass with the change. The node-http.test.ts cases also pass verbatim under node v26.3.0.

@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 reviewed this PR and didn't find any bugs. Because it redesigns how the uWS HTTP layer classifies idle connections (affecting both Bun.serve and node:http), touches the parser's fin-chunk delivery for the split-head Content-Length: 0 case, and carries an intentional behaviour change (res.end(); server.close() inside a handler no longer closes that handler's own connection), a human look would still be worthwhile.

What was reviewed:

  • betweenRequests maintenance in HttpContext::onData (set on fin before delivery, cleared on head parsed / partial head buffered) and its use in isIdle() / shouldCloseConnection() / isRequestTimedOut.
  • HttpParser.h ConsumeMinimally change: with remainingStreamingBytes == 0 the block emits an empty fin chunk (emittable = 0), matching the no-body branch and the single-read path.
  • Confirmed the removed AsyncSocketData::isIdle field / markDone(uwsRes) param / WebSocketData write have no remaining readers; isConnectRequest in isIdle() preserves the prior CONNECT-tunnel behaviour for both server personalities.
Extended reasoning...

Overview

This PR replaces the per-socket AsyncSocketData::isIdle flag (set at response end, cleared on any incoming bytes) with a derived HttpResponseData::isIdle() computed from three independently-maintained facts: a new betweenRequests bit (set when a request message's body fin arrives, cleared when the next head is parsed or a partial head is buffered), HTTP_RESPONSE_PENDING, and nodeHttpQueuedPipelinedCount. App::closeIdle, the HTTP_CLOSE_WHEN_IDLE gate in shouldCloseConnection(), and the node:http isRequestTimedOut sweep all now key on this. It also fixes an adjacent parser gap where a Content-Length: 0 request completed out of the fallback buffer never received its empty fin chunk. Mechanical fallout: markDone() loses its uwsRes parameter (four call sites updated in libuwsockets.cpp plus two in HttpResponse.h), and the dead WebSocketData isIdle = false write is removed. Three test files gain matrix / regression coverage.

Security risks

None identified. The change is server-side connection lifecycle bookkeeping; it does not touch auth, TLS, input validation, or any parsing of untrusted bytes beyond the narrow Content-Length: 0 fin-delivery fix (which only adds an empty-string dataHandler call that the single-read path already made).

Level of scrutiny

High. packages/bun-uws/src/HttpContext.h / HttpResponseData.h / HttpParser.h are the core HTTP request/response state machine shared by every Bun.serve and node:http connection. The PR redefines what "idle" means for closeIdleConnections() / graceful stop() and changes when shouldCloseConnection() fires under HTTP_CLOSE_WHEN_IDLE. The description explicitly calls out a user-visible behaviour change (res.end(); server.close() from inside a handler no longer closes that connection immediately, matching Node) — that is the correct behaviour by the stated contract, but it is the kind of semantic shift a maintainer should sign off on.

Other factors

The PR is exceptionally well-argued and the test coverage is strong: the node-http matrix test enumerates all eight connection states with positive-signal outcomes (no sleeps), the split-head Content-Length: 0 regression is covered for both the idle sweep and the requestTimeout 408 path, and the Bun.serve test exercises closeIdleConnections() mid-upload plus graceful stop() drain. I traced the betweenRequests transitions against the onData requestHandler/dataHandler control flow and the post-parse partial-head check; the ordering (fin sets it before the chunk is delivered, so a res.end() inside req.on('end') sees the connection idle) is deliberate and covered by the bodydone row. I also verified that in the new HttpParser.h branch, remainingStreamingBytes == 0 forces emittable = 0, so the added dataHandler call is exactly the empty fin chunk the no-body branch already emits — no body bytes are consumed from the fallback buffer. Given the breadth of the state machine touched and the intentional behaviour change, deferring to a maintainer rather than auto-approving.

@robobun

robobun commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

#48228 (draft) now covers the Bun.serve half of this PR with a different design. It computes the idle answer where stop() and closeIdleConnections() read it, and it stores no second bit.

What this PR has that #48228 does not:

A merge of this branch with main now conflicts in 7 of its 12 files.

@robobun

robobun commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as superseded. Each part of this PR is on main or in a newer open PR.

I ran the five tests that this PR adds on a debug build of main (2596ac5, Linux x64), without the source changes of this PR.

Test added here On main Where the fix is
node-http.test.ts: closeIdleConnections() state matrix (8 sweep rows, 5 close() rows) Passes Landed in #43557
node-http.test.ts: 'upgrade' and 'connect' tunnels survive the sweep Passes On main
node-http.test.ts: a sweep from a request handler spares the connection of that handler Fails: the server closes that connection #44310. The test passes with the src/js changes of #44310 on main.
node-http-server-timeouts.test.ts: no 408 after a Content-Length: 0 request whose head arrived in two reads Fails: 408, then the server closes the connection #46528. It has the HttpParser.h change and a copy of this test. I did not build that branch.
bun-server.test.ts: an answered upload that still arrives is not idle Fails: closedWhileUploading is 2 #48228. The test passes with the source changes of #48228 on main.

Two rules of this PR are not carried forward:

One difference in the Bun.serve part: on a graceful stop(), this PR waits for the rest of an answered upload. #48228 closes that connection at once and lists that under Downsides.

@robobun robobun closed this Nov 8, 2026
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