Skip to content

node:http: enforce server.maxConnections and emit 'drop' - #35022

Closed
robobun wants to merge 5 commits into
mainfrom
claude/37305967/http-server-maxconnections
Closed

robobun wants to merge 5 commits into
mainfrom
claude/37305967/http-server-maxconnections

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

server.maxConnections did nothing on an http.Server. With maxConnections = 2, every concurrent connection was accepted, every request answered, getConnections() counted them all, and no 'drop' event fired. The same option works on a plain net.Server (#953), but http.Server is backed by Bun.serve directly and never goes through net.Server's onconnection path where the check lives.

Repro

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

let served = 0, drops = 0;
const s = http.createServer((_q, r) => { served++; r.end("ok"); });
s.maxConnections = 2;
s.on("drop", () => drops++);
s.listen(0, "127.0.0.1", async () => {
  for (let i = 0; i < 4; i++) {
    const c = net.connect(s.address().port, "127.0.0.1");
    await new Promise(r => c.once("connect", r));
    c.on("error", () => {}); c.on("data", () => {});
    c.write("GET / HTTP/1.1\r\nHost: h\r\n\r\n");
    await new Promise(r => setTimeout(r, 50));
  }
  s.getConnections((_e, n) => console.log({ served, drops, n }));
});
// node: { served: 2, drops: 2, n: 2 }
// bun:  { served: 4, drops: 0, n: 4 }

Cause

src/js/node/_http_server.ts's onServerConnection (the uws accept filter registered by applyServerCustomOptions) wrapped every accepted socket in a NodeHTTPServerSocket and emitted 'connection' unconditionally. The maxConnections check in src/js/node/net.ts's onConnection is only reached by net.createServer()/tls.createServer(), not by the Bun.serve-backed http server.

Fix

Apply the same check net.Server uses, in onServerConnection: once the tracked-connection count reaches maxConnections, emit 'drop' with the same {localAddress, localPort, localFamily, remoteAddress, remotePort, remoteFamily} payload and close the native socket before a NodeHTTPServerSocket is created, so no 'connection'/'request' is dispatched for the excess.

Verification

New test in test/js/node/http/node-http.test.ts opens two keep-alive connections (served), then two more (dropped), checks served, 'drop' count and payload, getConnections(), and that freeing one slot lets the next connection through. The same logic passes under Node v26.

USE_SYSTEM_BUN=1 bun test node-http.test.ts -t maxConnections
  served: 4, drops: 0, outcomes: ["served","served"]   (fail)

bun bd test node-http.test.ts -t maxConnections
  7 expect() calls                                     (pass)

Related: #2793


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

fails on main (without fix)
ASAN without fix: 1 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
bun test v1.4.0 (e61b64774)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [417.87ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [82.57ms]
(pass) node:http > createServer > request & response body streaming (large) [134.72ms]
(pass) node:http > createServer > request & response body streaming (small) [83.95ms]
(pass) node:http > createServer > listen should return server [21.89ms]
(pass) node:http > createServer > listen callback should be bound to server [24.80ms]
(pass) node:http > createServer > should use the provided port [52.85ms]
(pass) node:http > createServer > should assign a random port when undefined [30.72ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [69.49ms]
(pass) node:http > response > set-cookie works with getHeader [4.70ms]
(pass) node:http > response > set-cookie works with getHeaders [5.99ms]
(pass) node:http > request > should not insert extraneous accept-encoding header [89.15ms]
(pass
... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (9f92ff453)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [9.25ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [3.54ms]
(pass) node:http > createServer > request & response body streaming (large) [5.49ms]
(pass) node:http > createServer > request & response body streaming (small) [2.70ms]
(pass) node:http > createServer > listen should return server [0.67ms]
(pass) node:http > createServer > listen callback should be bound to server [1.69ms]
(pass) node:http > createServer > should use the provided port [1.46ms]
(pass) node:http > createServer > should assign a random port when undefined [0.96ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [2.17ms]
(pass) node:http > response > set-cookie works with getHeader [0.07ms]
(pass) node:http > response > set-cookie works with getHeaders [0.10ms]
(pass) node:http > request > should not insert extraneous accept-encoding header [2.21ms]
(pass) node:http > request > multiple Set-Cookie headers works #6810 [10.75ms]
(pass) node:http > request > should make a standard GET request when passed string as first arg [5
... (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
bun test v1.4.0 (e61b64774)

test/js/node/http/node-http.test.ts:
(pass) node:http > createServer > hello world [576.20ms]
(pass) node:http > createServer > is not marked encrypted (#5867) [90.73ms]
(pass) node:http > createServer > request & response body streaming (large) [143.47ms]
(pass) node:http > createServer > request & response body streaming (small) [84.48ms]
(pass) node:http > createServer > listen should return server [21.84ms]
(pass) node:http > createServer > listen callback should be bound to server [52.95ms]
(pass) node:http > createServer > should use the provided port [43.48ms]
(pass) node:http > createServer > should assign a random port when undefined [28.76ms]
(pass) node:http > createServer > option method should be uppercase (#7250) [71.87ms]
(pass) node:http > response > set-cookie works with getHeader [5.07ms]
(pass) node:http > response > set-cookie works with getHeaders [6.28ms]
(pass) node:http > request > should not insert extraneous accept-encoding header [95.88ms]
(pass
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 830ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/22] 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/22] gen JS modules (bundle-modules)
Preprocess modules (7284ms)
Bundle modules (76ms)
Postprocesss modules (166ms)
Bundle Functions (649ms)
Generate Code (82ms)

[8.28s] Bundled "src/js" for production
  2042 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_output_tags v0.0.0 (/workspace/bun/src/bun_output_tags)
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_windows_sys v0.0.0 (/workspace/bun/src/windows_sys)
�[1m�[92m   Compiling�[0m bun_mimalloc_sys v0.0.0 (/workspace/bun/src/mim
... (truncated)
diff hotspot
src/js/node/_http_server.ts          | 20 +++++++++
 test/js/node/http/node-http-proxy.js |  4 +-
 test/js/node/http/node-http.test.ts  | 79 ++++++++++++++++++++++++++++++++++++
 3 files changed, 101 insertions(+), 2 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                  reads  edits  tests
src/js/node/_http_server.ts               5      2      0
test/js/node/http/node-http-proxy.js      1      1      0
test/js/node/http/node-http.test.ts       2      4      0

http.Server is backed by Bun.serve and never went through net.Server's
onconnection path, so the maxConnections check in net.ts was never
reached: with maxConnections = 2, every concurrent connection was
accepted, every request served, and no 'drop' event fired.

onServerConnection (the uws accept filter) now applies the same check
net.Server uses: once the tracked-connection count reaches
maxConnections, further accepts emit 'drop' with the peer/local address
data and close the socket before the connectionListener runs.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The HTTP server now enforces maxConnections when accepting sockets, emitting drop details and closing excess connections. A regression test verifies no requests are served for dropped clients, validates connection counts and payloads, and confirms capacity is reused.

HTTP connection limits

Layer / File(s) Summary
Drop excess connections at accept time
src/js/node/_http_server.ts, test/js/node/http/node-http.test.ts
onServerConnection rejects connections at the configured limit, while the test verifies drop events, socket closure, active counts, and subsequent slot reuse.

Possibly related PRs

  • oven-sh/bun#34432: Updates related maxConnections gating for cluster worker connections.
  • oven-sh/bun#34513: Updates accepted-socket destruction in another maxConnections drop path.

Suggested reviewers: cirospaciari

🚥 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: enforcing maxConnections on node:http servers and emitting 'drop'.
Description check ✅ Passed The description covers the problem, root cause, fix, repro, and verification, though it uses different headings than the template.

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

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:30 PM PT - Jul 21st, 2026

✅ @robobun, your commit e61b64774e4795e4245a7c9269e32ad11ac346f9 passed in Build #77322! 🎉


🧪   To try this PR locally:

bunx bun-pr 35022

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

bun-35022 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. http/http2: node v26.3.0 compat — HTTP/1 fallback + upgrade handoff, http2 session errors, perf_hooks and frame framing (+11 upstream tests) #34432 - Also implements maxConnections enforcement and 'drop' event emission in onServerConnection in _http_server.ts (as item Consider loading .env #7 of a larger node v26.3.0 compat PR)

🤖 Generated with Claude Code

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

#34432 does include an equivalent check in onServerConnection as one item of a much larger node v26.3.0 compat PR (42 files, currently conflicting with main). This PR is the same fix on its own so the connection-limit behaviour can land independently; whichever merges first, the other drops this hunk on rebase.

The two implementations differ only in ordering: #34432 constructs the NodeHTTPServerSocket first and then untracks it on drop (compare with >); this one checks against the handle before constructing the wrapper (compare with >=, matching net.ts's _connections >= maxConnections).

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

@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: 3

🤖 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 4125-4127: Bound the connection-count polling loop around
server.getConnections so it stops after a finite number of attempts or a bounded
wait window. After polling, assert the final connection count is 1 before
opening the replacement connection, while preserving the existing setImmediate
retry behavior.
- Line 4065: Add a separate fresh-server test case alongside the existing
server.maxConnections coverage that sets maxConnections to 0, then assert the
first connection is dropped, no request handler runs, and getConnections()
remains zero. Keep unset and positive-limit cases distinct to explicitly cover
each input space.
- Around line 4071-4080: Update openAndRequest and the corresponding promise
flow around the other affected socket setup to reject on client socket error
instead of using no-op "error" listeners. Preserve the existing close rejection
and response resolution behavior, ensuring connection failures cannot be
classified as a successful dropped outcome.
🪄 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: 8732d502-e762-4fac-99d3-741eff77d662

📥 Commits

Reviewing files that changed from the base of the PR and between 85ddc95 and cbdaf1f.

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

Comment thread test/js/node/http/node-http.test.ts
Comment thread test/js/node/http/node-http.test.ts
Comment thread test/js/node/http/node-http.test.ts
Comment thread src/js/node/_http_server.ts
Matches net.ts's ordering: a throwing 'drop' listener was leaving the
socket open, so the native caller swallowed the exception and
onNodeHTTPRequest went on to wrap and serve the over-limit connection
(Node does not serve it). The address reads stay before close so the
payload is populated.

@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 — the close-before-emit ordering fix addresses the last concern; the check now mirrors net.ts's maxConnections path exactly.

What was reviewed:

  • >= maxConnections comparison and != null guard match src/js/node/net.ts:997; kTrackedConnections.size is the same counter getConnections() reads.
  • socketHandle.remoteAddress/localAddress return {address, port, family} (same accessors NodeHTTPServerSocket uses at :1839-1847), and are captured before close().
  • Test now rejects on close-without-body and releases all clients + server in finally; the intentional no-op error handler on the drop-path clients is sound (ECONNRESET is expected there).
Extended reasoning...

Overview

Adds a maxConnections check to onServerConnection in src/js/node/_http_server.ts (~20 lines) so http.Server enforces the limit and emits 'drop' like net.Server already does. The check reads addresses, calls socketHandle.close(), then emits 'drop' with the standard 6-field payload — the same shape and ordering as the sibling at src/js/node/net.ts:997-1008. A ~80-line test in node-http.test.ts verifies served count, drop count, drop payload, getConnections(), and slot recovery after freeing a connection.

Security risks

None. This is a connection-limit enforcement that only makes the server more restrictive; no auth, crypto, or parsing of untrusted data is involved. The address fields in the drop payload are read from the native handle via the same accessors already used by NodeHTTPServerSocket.

Level of scrutiny

Moderate — Node compat code in the accept path. But the change is a direct port of the existing net.ts pattern (same != null guard, same >= comparison, same close-then-emit ordering), and socketHandle.close() / .remoteAddress / .localAddress are already exercised elsewhere in this file. The counter (kTrackedConnections.size) is the same one getConnections() reports, so accounting is consistent.

Other factors

This PR has been through two prior review rounds from me: (1) test hang-on-close and missing try/finally cleanup — both fixed in 9175b86; (2) emit-before-close ordering allowing a throwing 'drop' listener to bypass the limit — fixed in 9f92ff4. Three CodeRabbit findings were declined with reasoning I agree with (the ECONNRESET-tolerant no-op error handler is intentional; the = 0 boundary goes through the same comparison; the unbounded poll matches file convention). All threads are resolved. The overlap with #34432 was acknowledged — this is the standalone version of one hunk from that larger PR.

On hosts where localhost prefers ::1, listen(0, 'localhost') binds the
proxy to IPv6 while the client resolves to 127.0.0.1, so the test fails
with ECONNREFUSED independent of the code under test. Bind and connect
on the same literal address.

@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 — all three prior findings (test cleanup in finally, openAndRequest close-rejection, close-before-emit ordering) are addressed, and this run found nothing new.

What was reviewed:

  • The maxConnections != null guard means the new branch is a no-op unless the user explicitly sets maxConnections, so existing servers are unaffected.
  • socketHandle.remoteAddress/.localAddress/.close() are all existing native accessors already used elsewhere in this file; the >= comparison and payload shape match net.ts:997-1008.
  • onServerConnection is registered unconditionally via applyServerCustomOptions, so the check covers every accept.
  • The node-http-proxy.js fixture change is a trivial localhost → 127.0.0.1 hardening.
Extended reasoning...

Overview

This PR adds ~20 lines to onServerConnection in src/js/node/_http_server.ts so that http.Server#maxConnections is enforced the same way it already is for net.Server: once the tracked-connection set reaches the limit, the native handle is closed and a 'drop' event is emitted with the standard address payload, before any NodeHTTPServerSocket wrapper or 'connection'/'request' dispatch. It also adds a ~80-line test in node-http.test.ts covering served/drop counts, drop payload shape, getConnections(), and slot recovery, plus a two-line localhost → 127.0.0.1 binding fix in the node-http-proxy.js fixture.

Security risks

None. This is a resource-limiting feature (rejecting excess connections), not auth/crypto/permissions. The new code reads two address getters and calls .close() on a native handle — all pre-existing operations already used elsewhere in the same file. No user input is parsed.

Level of scrutiny

Medium-low. The accept path is hot, but the new branch is gated on maxConnections != null, and Server does not initialize maxConnections — it is undefined unless the user explicitly sets it. So for every existing server that hasn't set the option, this is a single != null check that falls through. The only affected users are those who set maxConnections and were previously getting silently-ignored behavior, which was the bug. The implementation is a direct copy of the sibling check in src/js/node/net.ts:997-1008 (same != null guard, same >= comparison, same payload keys, same close-before-emit ordering).

Other factors

I reviewed this PR twice previously and raised three findings (resource cleanup on assertion failure, openAndRequest hanging on clean close, and emit-before-close ordering allowing a throwing listener to bypass the limit). All three were fixed in commits 9175b86 and 9f92ff4 and the threads are resolved. CodeRabbit raised three more; two were declined with sound reasoning (intentional no-op error handler because the drop path can surface as ECONNRESET; unbounded poll matches file convention) and one was withdrawn. The bug-hunting system found nothing on the current revision. The test is hermetic (127.0.0.1, port 0, try/finally cleanup), asserts server-side invariants rather than timing, and the PR description confirms it fails under USE_SYSTEM_BUN=1 and passes under the debug build. The overlap with #34432 is acknowledged and non-blocking — whichever lands first, the other rebases.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: the same behavior landed on main in #34432. _http_server.ts now checks server.maxConnections on the accept path, destroys the excess connection and emits 'drop' with the same address fields (src/js/node/_http_server.ts, around line 1216). The test this PR adds to test/js/node/http/node-http.test.ts (http.Server maxConnections destroys the excess and emits 'drop' like net.Server) passes unmodified against a debug build of main at 04148c8, twice in a row.

@robobun robobun closed this Aug 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.

2 participants