Skip to content

node:http: answer every request past maxRequestsPerSocket with its own 503 - #33472

Closed
robobun wants to merge 19 commits into
mainfrom
farm/65f58994/http-max-requests-per-socket-pipeline
Closed

robobun wants to merge 19 commits into
mainfrom
farm/65f58994/http-max-requests-per-socket-pipeline

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

What

With server.maxRequestsPerSocket set, only the first request past the limit gets a 503 and a 'dropRequest' event. Bun then destroys the connection, so any requests the client pipelined behind it are never answered.

Node answers every request past the limit: each one gets its own 503 and its own 'dropRequest'.

Repro

import http from "node:http";
import net from "node:net";

const drops = [];
const srv = http.createServer((q, r) => { q.resume(); r.end("ok"); });
srv.maxRequestsPerSocket = 1;
srv.on("dropRequest", () => drops.push(1));
await new Promise(r => srv.listen(0, "127.0.0.1", r));

const raw = await new Promise(res => {
  const s = net.connect(srv.address().port, "127.0.0.1");
  let d = "";
  s.on("data", c => (d += c));
  s.on("error", () => {});
  // three requests pipelined into one segment
  s.on("connect", () =>
    s.write("GET /a HTTP/1.1\r\nHost: h\r\n\r\nGET /b HTTP/1.1\r\nHost: h\r\n\r\nGET /c HTTP/1.1\r\nHost: h\r\n\r\n"));
  setTimeout(() => { s.destroy(); res(d); }, 2000);
});

console.log(JSON.stringify({
  statuses: [...raw.matchAll(/HTTP\/1\.1 (\d{3})/g)].map(m => m[1]),
  dropRequestEvents: drops.length,
}));
srv.close(); srv.closeAllConnections();
node v26.3.0 : {"statuses":["200","503","503"],"dropRequestEvents":2}
bun (before)  : {"statuses":["200","503"],      "dropRequestEvents":1}
bun (after)   : {"statuses":["200","503","503"],"dropRequestEvents":2}

The same gap shows up without pipelining: after the limit is reached, a second request on the socket gets no response at all, because the connection is already gone.

Cause

src/js/node/_http_server.ts called socket.destroy() immediately after writing the over-limit 503:

if (reachedRequestsLimit) {
  server.emit("dropRequest", http_req, socket);
  http_res.writeHead(503);
  http_res.end();
  socket.destroy();   // <-- kills the parse loop mid-buffer
}

Requests are dispatched one at a time as the parser walks the socket's read buffer, so closing the handle there discards everything still sitting behind the current request.

Node's parserOnIncoming does not touch the connection on this path:

if (isRequestsLimitSet && (server.maxRequestsPerSocket < state.requestsCount)) {
  handled = true;
  server.emit('dropRequest', req, socket);
  res.writeHead(503);
  res.end();
}

It relies on the Connection: close header that maxRequestsOnConnectionReached already puts on the response. This is the documented behaviour: "When the limit is reached it will set the Connection header value to close, but will not actually close the connection, subsequent requests sent after the limit is reached will get 503 Service Unavailable as a response."

Bun already sets maxRequestsOnConnectionReached, so the Connection: close header was being advertised correctly and only the teardown was wrong.

Fix

Drop the socket.destroy(), and end the connection once the response pipeline has drained. In the over-limit branch:

if (!socket[kEndAfterDroppedRequests]) {
  socket[kEndAfterDroppedRequests] = true;
  setImmediate(endSocketAfterDroppedRequests, socket);
}

The deferred callback flips the flag from true to the symbol itself ("the current read is fully parsed") and ends the connection if nothing is in flight or queued. Otherwise onResponseFinishHandleSocket ends it once the pipeline drains, at the same queue-empty point it already checks before arming the keep-alive timeout. No per-response mark can go stale (a later read growing the queue past a marked tail) because nothing is marked.

The two flag states are needed because drainMicrotasks() in the per-request dispatch can fire a 503's own 'finish' before the next pipelined request is dispatched. Closing on the raw true flag there re-introduces the original truncation with a synchronous handler.

Why the close has to be deferred rather than dropped or immediate

Three placements, only the third works:

  • At the 503 (today's socket.destroy()). Tears the connection down mid-pipeline. This is the bug.
  • On the 503's "finish", via kMustCloseConnection. 'finish' fires before the next pipelined request is dispatched, so the pipeline is still truncated.
  • Deferred past the read, closing on pipeline drain (what this does). All buffered requests answered, then the connection ends.

Not closing at all was the first version of this PR and is wrong for Bun specifically. Node gets away with never closing because keepAliveTimeout reaps the socket. Bun had no keep-alive reaping (the merge that landed #32488 added it), and server.close() → stop() downgrades the JS reference to a Weak, so schedule_deinit → app.close() only runs once the wrapper is GC-finalized. That left test-http-keep-alive-drop-requests.js passing only when a GC happened to run.

Cost

This gives up node's documented "will not actually close the connection": a client that keeps sending past the limit now gets hung up on instead of an endless stream of 503s. In exchange the connection teardown is deterministic, which is what the reporter asked for ("keep draining the already-buffered pipelined requests ... and only then close").

Verification

Six tests in test/js/node/http/node-http.test.ts:

  • three requests pipelined into one segment produce 200/503/503 and two dropRequest events carrying the right req.url and the same socket
  • an over-limit request that carries a body does not stall the pipeline (its body still has to come off the wire before the parser reaches the next request)
  • the server ends the connection, but only after the pipelined requests behind the dropped one are answered
  • over-limit 503s queued behind an async response (two check phases out) are written before the connection ends
  • an over-limit request arriving in a later read is answered before the connection ends (barrier-driven, no wall-clock timing)
  • the existing Connection: close assertion, reworked so it no longer waits on the server-initiated close it was observing

Five of those fail on main with ["200", "503"] or ["200"].

test-http-keep-alive-drop-requests.js and test-http-keep-alive-pipeline-max-requests.js pass 5/5 each. The 402 test-http-* / test-https-* tests in test/js/node/test/parallel have the same failure set before and after.

Also in here

test/js/node/http/node-http-proxy.js listened on localhost and then dialled localhost. Where the resolver prefers IPv6 the server binds ::1, the client dials 127.0.0.1, and the connect is refused, so request via http proxy, issue#4295 fails on those hosts. Node fails the same way, so the test is not hermetic rather than the runtime being wrong. Pinned both ends to 127.0.0.1, matching the exampleSite() it talks to. Two lines, easy to split out if preferred.


[review] gate passed · iteration 6 · 3 files touched

fails on main (without fix)
ASAN without fix: 5 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-http.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (30c1c775c)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [612.32ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [136.42ms]
(pass) node:http > createServer > request & response body streaming (large) [173.84ms]
(pass) node:http > createServer > request & response body streaming (small) [104.08ms]
(pass) node:http > createServer > listen should return server [22.63ms]
(pass) node:http > createServer > listen callback should be bound to server [32.77ms]
(pass) node:http > createServer > should use the provided port [71.47ms]
(pass) node:http > createServer > should assign a random port when undefined [52.06ms]
(pass) node:http > createServer > option method should be upperca
... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (1721bc89a)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [10.66ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [4.84ms]
(pass) node:http > createServer > request & response body streaming (large) [5.34ms]
(pass) node:http > createServer > request & response body streaming (small) [3.25ms]
(pass) node:http > createServer > listen should return server [1.18ms]
(pass) node:http > createServer > listen callback should be bound to server [2.03ms]
(pass) node:http > createServer > should use the provided port [2.13ms]
(pass) node:http > createServer > should assign a random port when undefined [1.54ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [2.70ms]
(pass) node:http > response > set-cookie works with getHeader [0.08ms]
(pass) node:http > response > set-cookie works with getHeaders [0.10ms]
(pass) node:http > request > should not insert extraneous accept-encoding header [3.37ms]
(pass) node:http > request > multiple Set-Cookie headers works #6810 [13.45ms]
(pass) node:http > request > should make a standard GET request when passed string as first arg [
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-http.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (30c1c775c)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [461.12ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [83.93ms]
(pass) node:http > createServer > request & response body streaming (large) [154.66ms]
(pass) node:http > createServer > request & response body streaming (small) [92.46ms]
(pass) node:http > createServer > listen should return server [20.24ms]
(pass) node:http > createServer > listen callback should be bound to server [31.76ms]
(pass) node:http > createServer > should use the provided port [57.59ms]
(pass) node:http > createServer > should assign a random port when undefined [31.89ms]
(pass) node:http > createServer > option method should be uppercase
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 849ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/138] gen ErrorCode+*.h
[2/138] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[3/138] gen cpp.rs (cppbind)
[4/138] gen JS modules (bundle-modules)
Preprocess modules (9268ms)
Bundle modules (46ms)
Postprocesss modules (303ms)
Bundle Functions (997ms)
Generate Code (149ms)

[10.79s] Bundled "src/js" for production
  2036 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[4/138] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src i
... (truncated)
diff hotspot
src/js/node/_http_server.ts          |  62 ++++++----
 test/js/node/http/node-http-proxy.js |   7 +-
 test/js/node/http/node-http.test.ts  | 221 ++++++++++++++++++++++++++++++++---
 3 files changed, 250 insertions(+), 40 deletions(-)

gate history · 3 passed · 0 rejected · iteration 6

evidence per changed file
file                                  reads  edits  tests
src/js/node/_http_server.ts              16     21      0
test/js/node/http/node-http-proxy.js      1      1      0
test/js/node/http/node-http.test.ts      13     23      0

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Modifies the maxRequestsPerSocket over-limit handling in _http_server.ts so the dropRequest path ends the 503 response without destroying the socket, updates a related comment, adjusts a proxy test to bind to 127.0.0.1, and adds pipelining test helpers plus new tests validating the updated dropRequest/503 behavior.

Changes

maxRequestsPerSocket dropRequest fix and tests

Layer / File(s) Summary
Over-limit socket handling fix
src/js/node/_http_server.ts
The dropRequest handler now emits dropRequest, writes a 503, and ends the response without destroying the socket; comment updated to reflect Connection: close semantics.
Pipelining test helpers and new tests
test/js/node/http/node-http.test.ts
Adds pipelinedGet/pipelinedPost/pipelineRequests helpers, rewrites the existing over-limit test, and adds new tests validating per-request 503 responses, dropRequest events, and continued pipeline processing.
Proxy test IPv4 binding
test/js/node/http/node-http-proxy.js
Switches the proxy server and client request options from "localhost" to "127.0.0.1" to avoid IPv6 resolver/binding mismatches.

Possibly related PRs

  • oven-sh/bun#31587: Previously modified the same maxRequestsPerSocket over-limit/dropRequest logic and Connection header handling in _http_server.ts.
🚥 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 summarizes the main change: handling each over-limit request with its own 503 response.
Description check ✅ Passed It covers what the PR does and how it was verified, even though the headings differ from the template.

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

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:41 PM PT - Jul 17th, 2026

❌ @robobun, your commit 30c1c77 has some failures in Build #75052 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33472

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

bun-33472 --bun

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

LGTM — one-line removal aligning maxRequestsPerSocket with Node's documented behavior, with solid test coverage.

Extended reasoning...

Overview

The production change is a single deletion in src/js/node/_http_server.ts: removing socket.destroy() from the reachedRequestsLimit branch (plus comment adjustments). The rest of the diff is test coverage in test/js/node/http/node-http.test.ts: a shared pipelineRequests helper, two new tests (pipelined and sequential over-limit requests), and a rework of the existing Connection: close test that previously depended on the server-initiated destroy to resolve.

Security risks

None identified. maxRequestsPerSocket defaults to 0 (disabled), so this only affects servers that explicitly opt in. The over-limit path still does no user-handler work — it emits dropRequest and answers 503 immediately. The connection now stays open with Connection: close advertised rather than being torn down, which is exactly Node.js's documented contract ("will set the Connection header value to close, but will not actually close the connection"). This is not a DoS-protection knob; it exists for connection rotation, and the new behavior matches the reference implementation.

Level of scrutiny

Low-to-moderate. The behavioral change is one deleted line with a direct citation to Node's parserOnIncoming and the Node.js docs. maxRequestsOnConnectionReached was already being set correctly, so renderNativeHeaders already emits Connection: close on these responses — only the premature teardown was wrong. The reworked existing test now resolves by counting arrived responses instead of waiting for a server-side close that no longer happens, which is the necessary adjustment.

Other factors

The new tests await observable conditions (response count) rather than timers, wire close/error to resolve/reject so a regression produces an assertion failure instead of a hang, use port: 0, and clean up in finally. The author reports the 402 test-http-*/test-https-* Node parallel tests have identical pass/fail sets before and after. No CODEOWNERS cover this path and no prior reviewer comments are outstanding.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed one more test after the review: an over-limit request that carries a body (10cf56d847).

That path is distinct from the GET case. The 503'd request's body still has to come off the wire, otherwise the parser never reaches the request pipelined behind it. Three pipelined POSTs with a Content-Length body and maxRequestsPerSocket = 1 now assert 200/503/503 and two dropRequest events, matching node v26.3.0. It fails on main with ["200", "503"] like the others.

@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 didn't find any issues and the Node-compat reasoning is solid, but removing the over-limit socket.destroy() changes server connection lifetime with an acknowledged resource trade-off — worth a human sign-off.

Extended reasoning...

Overview

The production change is a single-line deletion in src/js/node/_http_server.ts: dropping socket.destroy() from the reachedRequestsLimit branch so that pipelined requests past maxRequestsPerSocket each get their own 503 + 'dropRequest' event, matching Node's documented parserOnIncoming behavior. The rest is comment updates and four well-constructed tests in test/js/node/http/node-http.test.ts (pipelined GETs, pipelined POSTs with bodies, sequential requests, and a rework of the existing Connection: close assertion).

Security risks

No injection/auth/data-exposure concerns. The relevant risk is resource exhaustion: without the destroy(), a client that ignores Connection: close can keep the socket open and keep collecting 503s. The PR explicitly analyzes this and argues (convincingly) that (a) Node does exactly this and reaps via keepAliveTimeout, and (b) Bun's node:http server already passes idleTimeout: 0 so no keep-alive socket is reaped today regardless — so this doesn't open a hole that wasn't already open. That analysis reads correct to me, but it's a judgment call about acceptable server behavior that a maintainer should confirm.

Level of scrutiny

Medium-high. The diff is tiny, but it removes a defensive connection teardown from the node:http server request-dispatch path. Connection-lifecycle changes in server code deserve a human look even when the compat justification is clear.

Other factors

  • The fix matches Node's source (parserOnIncoming does not destroy on this path) and Node's docs verbatim.
  • Tests are thorough: they cover the single-segment pipeline, the body-carrying pipeline (distinct parser path), and the non-pipelined sequential case; the helper resolves on close so a regression produces an assertion failure rather than a timeout.
  • Author verified the 402 test-http-*/test-https-* parallel tests have identical pass/fail sets before and after.
  • No prior human reviews or outstanding comments to address.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed that the connection-lifetime change is the part worth a human look, so here is the measurement behind the claim in the PR description rather than just the assertion.

No maxRequestsPerSocket anywhere in this script. It only asks whether an explicit keepAliveTimeout ever reaps an idle keep-alive connection:

import http from "node:http";
import net from "node:net";

const srv = http.createServer((q, r) => { q.resume(); r.end("ok"); });
srv.keepAliveTimeout = 500;
await new Promise(r => srv.listen(0, "127.0.0.1", r));

const t0 = Date.now();
const closedAfterMs = await new Promise(res => {
  const s = net.connect(srv.address().port, "127.0.0.1");
  s.on("data", () => {});            // must read, or the FIN never surfaces as 'close'
  s.on("error", () => {});
  s.on("close", () => res(Date.now() - t0));
  s.on("connect", () => s.write("GET /a HTTP/1.1\r\nHost: h\r\n\r\n"));
  setTimeout(() => { s.destroy(); res(-1); }, 8000);
});

console.log(closedAfterMs === -1 ? "never reaped (waited 8s)" : `reaped after ${closedAfterMs}ms`);
srv.close(); srv.closeAllConnections();
node v26.3.0 : reaped after 1531ms      (keepAliveTimeout 500 + keepAliveTimeoutBuffer 1000)
bun (main)   : never reaped (waited 8s)

So a client that holds a keep-alive socket open can already pin it indefinitely on main, with or without this PR. Server.prototype[kRealListen] passes idleTimeout: 0 to the underlying server, and server.keepAliveTimeout is only ever read to render the Keep-Alive: timeout=N header. Removing this one destroy() moves over-limit connections into the same bucket every other keep-alive connection is already in; it does not create a new class of exposure.

The missing idle reaping is a real gap and I'd be happy to open a separate issue or take a shot at it, but it's a behavior change for every keep-alive connection in node:http, so it does not belong in a fix this narrow.

The alternative I considered and rejected was deferring the close until the read buffer drains. It is timing-dependent, and it still truncates a pipeline that arrives across two TCP segments, which is the same bug report with a slightly different client.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Moved the maxRequestsPerSocket tests out of node-http.test.ts into their own test/js/node/http/node-http-max-requests-per-socket.test.ts (f91874bf5b). Same four tests, no assertions changed. The diff to node-http.test.ts is now a pure 31-line deletion of the one test that moved.

Two reasons:

  • node-http.test.ts contains request via http proxy, issue#4295, which can't connect in any sandbox that has HTTP_PROXY set (it fails with ECONNREFUSED on a local port, on main, unrelated to this change). That makes the whole 3,600-line file un-runnable in those environments, and it's the file my tests were landing in.
  • The directory already splits focused areas out this way: node-http-maxHeaderSize.test.ts, node-http-transfer-encoding.test.ts, node-http-connect.test.ts, node-http-backpressure.test.ts. A per-option file fits that layout.

The new file is 4/4 green with the fix and 3/4 red without it. Happy to fold it back into node-http.test.ts if you'd rather keep it there.

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

No bugs found and the fix matches Node's parserOnIncoming exactly, but since this removes an explicit socket teardown in the HTTP server request-limit path — and you've already flagged the connection-lifetime tradeoff yourself — I'd like a maintainer to sign off on that decision.

Extended reasoning...

Overview

The functional change is a single-line deletion in src/js/node/_http_server.ts: removing socket.destroy() from the reachedRequestsLimit branch so that every pipelined request past maxRequestsPerSocket receives its own 503 and 'dropRequest' event, matching Node.js's documented behavior. The rest of the diff is comment rewording, a new focused test file with four tests, and a pure deletion of one migrated test from node-http.test.ts.

Security risks

No injection, auth, or data-exposure surface. The relevant risk axis is resource exhaustion: without the destroy(), a client that ignores Connection: close can hold the socket open and keep collecting 503s. The PR's analysis is convincing that this exposure already exists for every keep-alive connection in Bun's node:http server (which passes idleTimeout: 0 and never enforces keepAliveTimeout), so the change doesn't create a new class of vulnerability — it moves over-limit sockets into the same bucket as every other keep-alive socket. Still, deliberately removing a teardown in server connection-management code is the kind of tradeoff a maintainer should ratify.

Level of scrutiny

Medium-high. The diff is tiny and mechanically correct, but it lives in the HTTP server's connection-lifecycle path, which is production-critical, and it changes when a socket is closed rather than what bytes are written. The author explicitly noted the connection-lifetime aspect is "worth a human look," and I agree.

Other factors

Test coverage is thorough (pipelined GETs, sequential requests, POSTs with bodies, and the pre-existing Connection: close assertion), verified against Node v26.3.0, and the author reports the 402-test node parallel http suite is unchanged. The reasoning about why deferring the close until buffer-drain would reintroduce the same bug is sound. This is close to approvable on the merits; I'm deferring only because the author themselves flagged it and because connection-teardown semantics in the HTTP server warrant explicit maintainer agreement.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my earlier comment, and a revert of the file split that went with it.

I claimed request via http proxy, issue#4295 fails because of HTTP_PROXY. That was wrong. The actual cause is in node-http-proxy.js: it does proxyServer.listen(0, "localhost") and then dials hostname: "localhost". Where the resolver prefers IPv6, the server binds ::1 and the client dials 127.0.0.1:

const s = createServer((q, r) => r.end("ok"));
s.listen(0, "localhost", () => {
  console.log("server bound:", JSON.stringify(s.address()));
  const req = request({ hostname: "localhost", port: s.address().port, path: "/" }, res => {
    res.resume();
    res.on("end", () => { console.log("OK status", res.statusCode); s.close(); });
  });
  req.on("error", e => { console.log("ERROR:", e.message); s.close(); });
  req.end();
});
node v26.3.0 : server bound: {"address":"::1","family":"IPv6",...}  ERROR: connect ECONNREFUSED 127.0.0.1:38087
bun          : server bound: {"address":"::1","family":"IPv6",...}  ERROR: connect ECONNREFUSED 127.0.0.1:33073

Node fails the same way, so this is a non-hermetic test rather than a runtime bug, and it only shows up on hosts whose resolver returns ::1 first. Pinned both ends to 127.0.0.1, which is what exampleSite() already binds (358dd0e43e). Two lines; happy to pull it into a separate PR if you'd rather not carry it here.

With that file runnable end to end, the separate test file had no reason to exist, so the four tests are back in node-http.test.ts beside the rest of the node:http server coverage (4c02e1470a). Net diff is now three files: the one-line socket.destroy() removal, the tests, and the proxy-test pin.

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

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/node/http/node-http.test.ts`:
- Around line 3515-3643: These socket-spawning tests are independent and should
run concurrently instead of sequentially. Update the four new test cases to use
it.concurrent (or the equivalent concurrent test form in this file) in
node-http.test.ts, keeping the same bodies and assertions, so each test still
creates its own server and uses its own dynamic port without shared state.
🪄 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: 8574e214-2ed5-4d99-94ce-5ba07ee1fbb8

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 4c02e14.

📒 Files selected for processing (3)
  • src/js/node/_http_server.ts
  • test/js/node/http/node-http-proxy.js
  • test/js/node/http/node-http.test.ts

Comment thread test/js/node/http/node-http.test.ts Outdated
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.

The one-line fix and tests look correct and match Node's documented behavior, but since this removes an active socket teardown from a resource-limiting path (and leans on the pre-existing keepAliveTimeout gap you documented), a maintainer should sign off on the connection-lifetime tradeoff.

Extended reasoning...

Overview

The functional change is a single-line deletion in src/js/node/_http_server.ts: removing socket.destroy() from the maxRequestsPerSocket over-limit path so that every pipelined request past the limit gets its own 503 + dropRequest event, matching Node's parserOnIncoming and its documented behavior. The rest of the diff is test coverage (three new pipelining tests plus a rework of the existing Connection: close test in node-http.test.ts) and an unrelated hermetic-test fix in node-http-proxy.js (localhost → 127.0.0.1).

Security risks

maxRequestsPerSocket is a resource-limiting knob. Previously Bun forcibly tore down the connection once the limit was exceeded; after this change the connection stays open and keeps answering 503s until the client closes it or an idle timeout fires. The author correctly points out that Bun's node:http server currently passes idleTimeout: 0 and never reaps keep-alive sockets anyway, so this doesn't open a new class of exposure — over-limit sockets just join the same unbounded-lifetime bucket every other keep-alive socket is already in. That argument is sound, but it's still a deliberate relaxation of teardown behavior on a limiting path, and the author themselves flagged it as "the part worth a human look."

Level of scrutiny

This is production HTTP server request-dispatch code. The mechanical change is trivial and clearly matches upstream Node, but the semantic change (connection lifetime after the request cap is hit) is a judgment call a maintainer should make — particularly whether to land this as-is and track the missing keepAliveTimeout reaping separately, or require both to land together.

Other factors

The tests are well-constructed: port: 0, error handlers wired to reject, completion driven by counting response status lines rather than sleeps, and a close fallback so a mid-pipeline teardown surfaces as an assertion failure instead of a hang. The node-http-proxy.js change is a straightforward test-hermeticity fix. No prior human review on the thread; no bugs found by the bug-hunting pass.

robobun added 7 commits July 6, 2026 14:13
…n 503

The over-limit handler wrote one 503 and then destroyed the socket, which
tore down the connection while requests pipelined behind it were still in
the read buffer. Only the first request past the limit got a response and
a 'dropRequest' event.

Node's parserOnIncoming does not touch the connection here: each over-limit
request gets its own 'dropRequest' and its own 503, and the response already
advertises Connection: close.
A dropped request's body still has to be read off the wire, or the parser
never reaches the request pipelined behind it.
node-http.test.ts carries an unrelated proxy test that cannot connect in a
sandbox with HTTP_PROXY set, which makes the whole file un-runnable there.
The per-option layout matches node-http-maxHeaderSize.test.ts and friends.
The file uses bun:test directly, like its sibling focused files.
proxyServer.listen(0, "localhost") binds ::1 wherever the resolver prefers
IPv6, while the client dials 127.0.0.1 and the connect is refused. Node fails
the same way there, so the test is not hermetic rather than the runtime being
wrong. exampleSite() already binds 127.0.0.1.
It was split out only because that file could not run to completion; with the
proxy test pinned it can, so the tests go back beside the rest of the node:http
server coverage.
The sequential test had its own copy of the connect/accumulate/close/error
scaffolding; only the point at which the next request is written differed.
Also run the four tests concurrently, they share no state.

@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 didn't find any bugs, but this removes the socket teardown on the maxRequestsPerSocket over-limit path — a connection-lifetime change to a resource-limiting mechanism that (as you noted yourself) is worth a human look, especially given Bun doesn't yet reap idle keep-alive sockets.

Extended reasoning...

Overview

The runtime change is a single line: remove socket.destroy() from the reachedRequestsLimit branch in src/js/node/_http_server.ts, so that every pipelined request past maxRequestsPerSocket gets its own 503 + 'dropRequest' event instead of the connection being torn down after the first one. This matches Node's parserOnIncoming and its documented behavior. Alongside it: four new tests in node-http.test.ts (pipelined GET, pipelined POST-with-body, sequential-after-limit, and a rework of the existing Connection: close assertion), plus an unrelated hermeticity fix pinning node-http-proxy.js to 127.0.0.1.

Security risks

maxRequestsPerSocket is a resource-limiting knob. Removing the forced teardown means a client that ignores Connection: close can keep the socket open and keep collecting 503s. The author's argument — that Bun already passes idleTimeout: 0 and never reaps idle keep-alive sockets, so this moves over-limit connections into the same bucket every other keep-alive connection is already in rather than opening a new hole — is well-reasoned and empirically demonstrated in the PR thread. It also matches Node exactly. But it's still a deliberate loosening of a limit-enforcement path that a maintainer should sign off on, particularly the decision to defer the idle-reaping gap to a separate change.

Level of scrutiny

Medium-high. The diff is tiny, the Node-compat justification is solid, and test coverage is thorough (three of the four new tests fail on main). But this is the core node:http server request path, and the author explicitly flagged the connection-lifetime aspect as "the part worth a human look." That's the right call.

Other factors

  • CodeRabbit's two review comments (dedupe socket-collection helper, use it.concurrent) were both addressed in commit 2eb1eaa and marked resolved.
  • The node-http-proxy.js localhost→127.0.0.1 change is a straightforward test-hermeticity fix and low risk on its own.
  • No prior human reviews on the PR; CI build was still in progress at the time of the last timeline entry.

@robobun
robobun force-pushed the farm/65f58994/http-max-requests-per-socket-pipeline branch from 2eb1eaa to e2b4ca4 Compare July 6, 2026 14:34
Comment thread src/js/node/_http_server.ts
Not closing the socket at all left the close to whenever GC finalized the
Bun.serve wrapper: `Server.prototype.close` drops the JS reference and
`stop()` downgrades it to a Weak, so `schedule_deinit` -> `app.close()` only
runs once the wrapper is collected. With no keep-alive reaping in Bun, a
connection whose requests all completed could stay open indefinitely, and
test-http-keep-alive-drop-requests.js only passed when a GC happened to run.

Ending the socket on the over-limit response's "finish" is too early - the
requests pipelined behind it have not been parsed yet. Defer it instead, so
the current read is drained, every buffered request gets its own 503, and
only then does the connection end.
Comment thread test/js/node/http/node-http.test.ts Outdated
robobun added 2 commits July 6, 2026 17:49
Replacing the sequential test left the "sequential" branch of the helper
without a caller.
Unrelated flakes on install/run/spawn lanes (spawn.test.ts timeout,
spawn-pipe-leak, bun-install, no-orphans), all with retries; none touch the
node:http diff.

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

Both of my earlier concerns are addressed and I found nothing further, but the deliberate divergence from Node's documented "will not actually close the connection" behavior (as a workaround for the pre-existing keep-alive-reaping gap) is a design tradeoff worth a maintainer's sign-off.

Extended reasoning...

Overview

The runtime change is small: in src/js/node/_http_server.ts, the reachedRequestsLimit branch replaces socket.destroy() with a one-shot setImmediate(endSocketAfterDroppedRequests, socket), guarded by a new kEndAfterDroppedRequests symbol so it's scheduled once per socket. A new module-level helper and symbol are added. test/js/node/http/node-http.test.ts gains a sendRequests helper and four it.concurrent tests covering pipelined GETs, pipelined POSTs with bodies, the Connection: close header on the 503, and server-initiated connection close. test/js/node/http/node-http-proxy.js pins both ends to 127.0.0.1 (unrelated hermeticity fix).

Security risks

None identified. The change affects only the over-limit path when maxRequestsPerSocket is set (default 0/off). Deferring the close via setImmediate rather than destroying immediately does not create a new resource-exhaustion vector — the socket is still ended deterministically after one event-loop turn, and the author measured that idle keep-alive connections were already unreaped on main regardless of this option.

Level of scrutiny

Medium-high. This is production node:http server code on a connection-lifetime path, and the fix rests on a uWS implementation detail (all requests in a single read buffer are dispatched synchronously within one onData, so setImmediate runs strictly after the last pipelined request). The PR also intentionally diverges from Node's documented behavior — Node keeps the connection open and answers indefinitely with 503s; this ends it after draining the current buffer — as a pragmatic workaround for Bun's missing keepAliveTimeout reaping. That tradeoff is clearly stated in the PR description and comments, but it's the kind of Node-compat call a maintainer should ratify.

Other factors

Both of my earlier inline comments were addressed with measured evidence: the GC-dependent close in test-http-keep-alive-drop-requests.js was fixed by the deferred-end approach (52ab594), and the dead "sequential" mode in the test helper was removed (3a9ffbc). CodeRabbit's dedup/concurrency suggestions were also applied. The four new tests are hermetic (own server on port 0, error wired to reject, resolve on close), and the author reports the vendored Node parallel tests are unchanged. The remaining commit since my last review (45698bcf) is a CI retrigger with no code change.

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green, CI red is infra/unrelated

Build 75052 (at 30c1c775) started with 24 jobs expiring in the queue, then after re-running the finished lanes showed the following failures, all with retries, none referencing node:http, maxRequestsPerSocket, dropRequest, or any of the three files in this diff:

  • bun-server.test.ts — Bun.serve test; fails identically (same 4 tests) on origin/main locally; the path is _http_server.ts (node:http), not src/runtime/server/
  • serve-protocols.test.ts — HTTP3StreamReset on an HTTP/3 POST (QUIC transport flake); this PR is HTTP/1.1-only
  • serve.test.ts, no-orphans, require-cache, complex-workspace, filter-workspace, fs-promises-file-handle-readFile, es-module-lexer, 20144 — Bun.serve range/cli/fs/third-party, all with retries

onResponseFinishHandleSocket is only referenced from _http_server.ts, and kEndAfterDroppedRequests is only set when maxRequestsPerSocket > 0, which Bun.serve tests do not use; the change is inert for every lane that went red. The robobun/evidence check passed ("test fails without the fix and passes with it on ASAN and release builds").

Earlier builds (69181, 69271, 74720) each went red on a different set of unrelated install/spawn/napi/fs/webview flakes.

Locally node-http.test.ts is 137 pass / 0 fail, five of the new tests fail on main, and the 402 test-http-*/test-https-* parallel tests have the same failure set before and after.

I've used my one empty retrigger and I'm not going to push empty commits for queue backlogs or unrelated flakes. This is ready for a maintainer; the one call worth a human eye is the connection-lifetime tradeoff described in the PR body (deterministic teardown vs. Node's "never close"), which the automated reviews also flagged for sign-off.

robobun added 2 commits July 17, 2026 21:37
…eline

Resolve conflict in _http_server.ts: keep the deferred-end approach
(setImmediate after the read buffer is fully parsed) and make it
pipeline-aware so a queued 503 behind an async response closes after
the last queued response drains rather than mid-pipeline.
On main the first queued 503 carries kMustCloseConnection, so socket.end()
runs on its 'finish' and advanceResponsePipeline bails before the second
queued 503 is written. The deferred end now marks the last queued response
instead, so the whole pipeline drains.
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Merged main (post-#32488) and resolved the conflict in _http_server.ts.

#32488 added an isPipelined branch to the over-limit path that sets kMustCloseConnection on each queued 503; on its 'finish' that ends the socket and advanceResponsePipeline bails, so the second queued 503 was still dropped. The deferred-end now checks for queued responses at setImmediate time: if the pipeline is still draining it marks the last queued response as must-close instead of ending the socket outright, so every queued 503 reaches the wire.

New test covers that path (async handler so the 503s are queued behind an in-flight response): fails on main with ["200", "503"], passes here.

# bun bd /tmp/repro.mjs
round0: 200s=2 503s=2 dropRequest=2
round1: 200s=2 503s=2 dropRequest=2
round2: 200s=2 503s=2 dropRequest=2

node-http.test.ts: 136 pass. test-http-keep-alive-drop-requests, test-http-keep-alive-pipeline-max-requests, test-http-pipeline-*, test-http-keepalive-request all pass.

Comment thread src/js/node/_http_server.ts
Comment thread test/js/node/http/node-http.test.ts Outdated
robobun added 2 commits July 18, 2026 01:39
…arked response

The deferred end snapshotted the queue tail at check-phase time and marked
it with kMustCloseConnection. An over-limit request arriving in a later read
while the first response was still in flight grew the queue past that tail,
so when the marked response finished the socket closed before the later 503
was replayed: N dropRequest events, <N 503s on the wire.

Move the close decision out of per-response marks. The setImmediate now just
flips kEndAfterDroppedRequests from "scheduled" to "fired", and
onResponseFinishHandleSocket ends the connection when the pipeline has
drained (the same queue-empty check it already makes before arming the
keep-alive timeout). The two states keep a 503's own 'finish' during the
per-request drainMicrotasks() from closing the socket before the next
pipelined request is dispatched.

Tests:
- the async-handler test now outlives two check phases so the deferred end
  observes a queued pipeline
- a new test pins the later-read case: /a+/b in one read, then /c after the
  deferred end has fired, then the handler responds
@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status update: redesigned the deferred end after two more review catches

Both about the post-#32488-merge reconciliation. Both verified with probes, both fixed in ab4a04c060.

  • Later-read over-limit request gets its 503 dropped. The merged code marked the queue tail at check-phase time with kMustCloseConnection. A later read could grow the queue past that tail while the first response was still in flight; when the marked response finished, the socket closed before the later 503 was replayed. Reproduced: 2 dropRequest events, ["200","503"] on the wire.
  • queue?.length branch untested. Deleting it left every test passing. The suggested setTimeout(10) handler doesn't reach it either (debug-build onData takes ~40-50ms, so 10ms expires mid-parse and fires before the setImmediate). A 500ms handler does truncate to ["200"] exactly as traced.

New approach: stop marking per-response entirely. endSocketAfterDroppedRequests now just flips kEndAfterDroppedRequests from "scheduled" (true) to "fired" (the symbol itself), and onResponseFinishHandleSocket ends the connection on pipeline drain, at the same queue-empty point it already checks before arming the keep-alive timeout. No mark can go stale because nothing is marked. The two states stop a 503's own 'finish' during per-request drainMicrotasks() (line 871) from closing the socket before the next pipelined request is dispatched.

Tests: one new (later-read, barrier-driven, no wall-clock timing); the async-handler test hardened to setImmediate(() => setImmediate(() => res.end())) so the deferred end deterministically observes a queued pipeline. 137 pass / 0 fail locally, 5 tests fail on main.

The previous CI builds (69181, 69271, 74720) each went red on a different set of install/spawn/napi/fs/webview flakes; none referenced node:http. This push carries a real change, so no retrigger spent.

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

Racing the second 'dropRequest' against the socket closing turns a
regression that ends the connection before /c is dispatched into an
assertion failure instead of a hang on the missing event. The finally now
releases the handler barrier and destroys the socket, so a failure does not
leave /a parked and the connection un-reapable.
Comment thread src/js/node/_http_server.ts Outdated
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.

No bugs found this pass, but this changes connection-teardown semantics in the node:http server and deliberately diverges from Node's documented "will not actually close the connection" behavior — that tradeoff (which the PR body itself flags) is a maintainer call, so deferring rather than auto-approving.

Checked: the tri-state kEndAfterDroppedRequests flag against mid-dispatch drainMicrotasks() closes — the === symbol guard holds. The new socket.end() in onResponseFinishHandleSocket sits after both the in-flight and queued-pipeline early returns, so it can't fire while responses are pending. Earlier concerns (later-read truncation, untested queue branch, test cleanup on the barrier path, comment-length cap) are all addressed in the current diff.

Extended reasoning...

Overview

The PR replaces the immediate socket.destroy() in the maxRequestsPerSocket over-limit path with a deferred close-on-drain: a tri-state kEndAfterDroppedRequests symbol (true = scheduled, symbol-itself = fired) is set on the socket, endSocketAfterDroppedRequests runs at setImmediate, and onResponseFinishHandleSocket ends the connection once the pipelined-response queue is empty. Six new tests in node-http.test.ts cover single-segment pipelines, POST bodies, async handlers (two check phases out), a barrier-driven later-read case, and server-initiated close. node-http-proxy.js is a two-line hermeticity fix (localhost → 127.0.0.1).

Security risks

None identified. This is request-limit accounting on an already-accepted connection; no auth, crypto, or untrusted-input parsing changes. The over-limit path already existed — only the teardown timing changed.

Level of scrutiny

High. _http_server.ts is a hot path in a hostile-userland builtin, and connection lifecycle here interacts with the pipelined-response queue, drainMicrotasks() inside the per-request dispatch loop, and the keep-alive timeout added in #32488. Two earlier iterations of this fix had real correctness bugs (later-read truncation via stale tail-marking; an untested load-bearing branch), both caught in review and both fixed by the current close-on-drain redesign. That churn is a signal the interactions are subtle.

Other factors

The PR explicitly trades away Node's documented "will not actually close the connection" for deterministic teardown after the buffered pipeline drains. That's defensible (Bun's keep-alive reaping only just landed, and the reporter asked for exactly this), but it's a Node-compat design choice a maintainer should sign off on rather than an auto-approve. All prior inline review threads are resolved; test coverage is thorough and barrier-driven with proper failure wiring and cleanup.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-18, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 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