Skip to content

node:http: adopt external sockets fed in via server.emit('connection') - #35285

Open
robobun wants to merge 13 commits into
mainfrom
farm/a11db72d/http-emit-connection
Open

robobun wants to merge 13 commits into
mainfrom
farm/a11db72d/http-emit-connection

Conversation

@robobun

@robobun robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #35281

Problem

Node's http.Server registers an internal connection listener that attaches an HTTPParser to every socket passed through the event, so user code can feed arbitrary Duplex streams (e.g. a TLS-terminated socket from a proxy tunnel) into http.createServer() via server.emit('connection', socket). In Bun, HTTP parsing happens natively in uWS and only for sockets the server itself accepted, so emitting a foreign socket attached no parser and request never fired.

const httpServer = http.createServer((req, res) => res.end('hello ' + req.url));
const raw = net.createServer(sock => httpServer.emit('connection', sock));
// Node: 'request' fires; Bun: timeout, request event never fired

Fix

Port Node v26.3.0's connectionListener / connectionListenerInternal (lib/_http_server.js) and its socket/parser plumbing for sockets that are not native NodeHTTPServerSocket wrappers:

  • attaches a REQUEST-mode llhttp parser (the existing process.binding('http_parser') binding, driven through node:_http_common's parser freelist) to the adopted duplex
  • builds IncomingMessage/ServerResponse over the duplex using the existing standalone response path (assignSocket + OutgoingMessage header rendering/chunked framing)
  • includes keep-alive + pipelining queue, resOnFinish socket lifecycle, upgrade/CONNECT handoff, clientError (raw 400/431 replies), Expect: 100-continue / checkContinue / checkExpectation, maxRequestsPerSocket 503 + dropRequest, requireHostHeader, server/keep-alive timeouts
  • natively accepted connections skip the listener (instanceof check), so the uWS fast path is unchanged
  • exports http._connectionListener like Node

Also extends the shared socketOnError raw-reply guard to honor _headerSent, which only standalone (adopted-socket) responses maintain; native responses keep using headerStateSymbol and are unaffected.

Review follow-ups hardened the handle-less ServerResponse surfaces the new path exercises: end() always routes through OutgoingMessage.end, and socket/writableLength/writableNeedDrain/writableFinished fall through to the OutgoingMessage accounting when there is no native handle (kChunkedLength is exported from node:_http_outgoing for exactly that getter). Upgrade requests skip their body at the parser (headers-complete returns 2, like Node before its UpgradeStream), so bodied upgrades cannot stall on readable backpressure; body and tunnel bytes reach the 'upgrade'/'connect' listener as bodyHead + raw 'data' events.

Verification

  • the issue's repro passes (PASS — got request), identical behavior on Node v26
  • new tests in test/js/node/http/node-http-emit-connection.test.ts cover request dispatch, keep-alive, request bodies, chunked responses, clientError, default 400, upgrade, 100-continue, the native path, and the _connectionListener export; all 10 fail on bun 1.4.0 and pass with this change (verified against Node for parity)
  • no regressions in the http/http2-fallback suites (node-http.test.ts, CONNECT, server timeouts, backpressure, test-http2-https-fallback*, test-http-server-unconsume-consume, clientError node tests)

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

fails on main (without fix)
ASAN without fix: 16 FAILED
$ 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-emit-connection.test.ts
bun test v1.4.0 (42afacffa)

test/js/node/http/node-http-emit-connection.test.ts:
(fail) dispatches 'request' for sockets emitted via emit('connection') [5000.80ms]
  ^ this test timed out after 5000ms.
(fail) keep-alive serves sequential requests on one adopted socket [5003.75ms]
  ^ this test timed out after 5000ms.
(fail) request body flows through the adopted socket [5007.17ms]
  ^ this test timed out after 5000ms.
(fail) chunked response when no content-length is known [5015.54ms]
  ^ this test timed out after 5000ms.
(fail) auto 400 on a pipelined request queued behind an in-flight response [5005.92ms]
  ^ this test timed out after 5000ms.
(fail) queued pipelined response with Content-Length and write() before end() [5000.40ms]
  ^ this test timed out after 5000ms.
(fail) writableNeedDrain and 'drain' track buffered writes on an adopted socket [5004.38ms]
  ^ this test timed out after 5000ms.
(fail) malformed request emits 'clientError' [5004.13ms]
  ^ this test timed out after 
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (607236689)

test/js/node/http/node-http-emit-connection.test.ts:
(pass) malformed request emits 'clientError' [26.41ms]
(pass) server sockets accepted natively are unaffected [25.50ms]
293 |     res.end();
294 |   });
295 |   ctx.client.write("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\nExpect: 100-continue\r\n\r\n");
296 |   const closed = new Promise<void>(resolve => ctx.client.on("close", () => resolve()));
297 |   const buf = await ctx.reader.until("401 Unauthorized");
298 |   expect(buf).toContain("Connection: close");
                    ^
error: expect(received).toContain(expected)

Expected to contain: "Connection: close"
Received: "HTTP/1.1 401 Unauthorized\r\nDate: Thu, 23 Jul 2026 19:18:01 GMT\r\nConnection: keep-alive\r\nKeep-Alive: timeout=5\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n"

      at <anonymous> (/workspace/bun/test/js/node/http/node-http-emit-connection.test.ts:298:15)
(pass) dispatches 'request' for sockets emitted via emit('connection') [33.94ms]
(pass) request body flows through the adopted socket [30.79ms]
(pass) chunked response when no content-length is known [30.61ms]
(pass) upgrade request with a
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-http-emit-connection.test.ts
bun test v1.4.0 (42afacffa)

test/js/node/http/node-http-emit-connection.test.ts:
(pass) dispatches 'request' for sockets emitted via emit('connection') [1144.28ms]
(pass) request body flows through the adopted socket [1021.13ms]
(pass) chunked response when no content-length is known [1024.87ms]
(pass) auto 400 on a pipelined request queued behind an in-flight response [1025.37ms]
(pass) keep-alive serves sequential requests on one adopted socket [1179.35ms]
(pass) malformed request emits 'clientError' [242.02ms]
(pass) queued pipelined response with Content-Length and write() before end() [355.35ms]
(pass) malformed request gets the default 400 response [442.42ms]
(pass) 'upgrade' hands the adopted socket to the listener [407.53ms]
(pass) upgrade request with a body larger than the high-water mark does not stall [245.73ms]
(pass) writableNeedDrain and 'drain' track buffered writes on an adopted socket [712.35ms]
(pass) upgrade request with a body spanning packets hands off at end of
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 869ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (7292ms)
Bundle modules (30ms)
Postprocesss modules (199ms)
Bundle Functions (666ms)
Generate Code (27ms)

[8.23s] Bundled "src/js" for production
  2085 kb
  167 internal modules
  13 native modules
  90 internal functions across 19 files
[1/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_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m
... (truncated)
diff hotspot
src/js/node/_http_outgoing.ts                      |   1 +
 src/js/node/_http_server.ts                        | 482 ++++++++++++++++++++-
 src/js/node/http.ts                                |   3 +-
 .../js/node/http/node-http-emit-connection.test.ts | 341 +++++++++++++++
 4 files changed, 807 insertions(+), 20 deletions(-)

gate history · 9 passed · 0 rejected · iteration 1

evidence per changed file
file                                                 reads  edits  tests
src/js/node/_http_outgoing.ts                            1      1      0
src/js/node/_http_server.ts                             26     22      0
src/js/node/http.ts                                      1      1      0
test/js/node/http/node-http-emit-connection.test.ts      2     15      0

root cause · written by the author bot

When a request arrived with Expect: 100-continue on an adopted socket, the _expect_continue flag was set but never read, so a handler that rejected the request without sending 100 Continue still produced a keep-alive response and left the socket open. Since the client is allowed to send the request body anyway, those body bytes were then parsed as a fresh request, producing spurious parse errors where Node cleanly closes the connection. The fix ports Node's check into the standalone writeHead path, clearing shouldKeepAlive when a final status is written before any 100 Continue, so the res…

Node's http.Server registers a 'connection' listener that attaches an
HTTPParser to any duplex passed through the event, so user code can
terminate TLS elsewhere (or bridge any stream) and still receive
'request' events. Bun parsed only natively accepted uWS connections, so
server.emit('connection', socket) registered no parser and 'request'
never fired.

Port Node v26.3.0's connectionListener/connectionListenerInternal and
its socket/parser plumbing (parserOnIncoming, resOnFinish, pipelining
queue, keep-alive timeout, upgrade/CONNECT handoff, clientError,
Expect: 100-continue, maxRequestsPerSocket, requireHostHeader) for
sockets that are not native NodeHTTPServerSocket wrappers, driving the
existing llhttp binding through node:_http_common and the standalone
ServerResponse path (assignSocket + OutgoingMessage framing). Also
export http._connectionListener like Node.

Fixes #35281
@robobun

robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:16 PM PT - Jul 23rd, 2026

❌ @robobun, your commit 42afacf has 1 failures in Build #78798 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35285

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

bun-35285 --bun

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Bun’s Node HTTP server now parses externally emitted duplex sockets, supports request, upgrade, CONNECT, error, and keep-alive flows, updates standalone response state handling, exposes internal listener symbols, and adds comprehensive adopted-socket tests.

Changes

HTTP adopted-socket support

Layer / File(s) Summary
Connection parser and lifecycle
src/js/node/_http_server.ts
Registers connectionListener and implements llhttp parsing, socket lifecycle handling, backpressure, request dispatch, upgrade/CONNECT handling, expectation checks, keep-alive behavior, and teardown.
Response state and public wiring
src/js/node/_http_server.ts, src/js/node/http.ts, src/js/node/_http_outgoing.ts
Updates standalone response completion, socket and writable-state accounting, encoding delegation, and exports connectionListener and kChunkedLength.
Adopted-socket behavior validation
test/js/node/http/node-http-emit-connection.test.ts
Tests adopted requests, streaming, keep-alive, chunked responses, pipelining, backpressure, malformed input, upgrades, CONNECT, Expect: 100-continue, native connections, and direct listener invocation.

Possibly related PRs

Suggested reviewers: cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The new kChunkedLength export in _http_outgoing.ts is unrelated to external-socket adoption and looks out of scope. Remove that export or explain why this unrelated API surface change is required for the socket-adoption fix.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement #35281 by attaching parsers to emitted Duplex sockets and restoring Node-compatible request handling.
Title check ✅ Passed The title clearly summarizes the main change: supporting external sockets passed through server.emit('connection').
Description check ✅ Passed The description covers the problem, fix, and verification, though it uses custom headings instead of the template wording.

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

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

🤖 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 `@src/js/node/_http_server.ts`:
- Line 1522: Fix duplicate conditional property accesses in
src/js/node/_http_server.ts at lines 1522, 1532, 1583, 1632, 1634, 1650, 1680,
and 1828 by destructuring each referenced property into a local before its
guard, condition, or use: server.timeout, server.maxHeadersCount, socket.parser,
state.outgoing, socket._httpMessage, parser.incoming, socket.parser, and
req.headers.expect respectively. Reuse those locals throughout each affected
branch without changing behavior.
🪄 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: 01d3826f-d523-47a2-9baf-a6629bd7e84e

📥 Commits

Reviewing files that changed from the base of the PR and between 892b1da and 9512c49.

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

Comment thread src/js/node/_http_server.ts Outdated
@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 server.emit('connection', socket) foreign-socket adoption with connectionListener driving llhttp from JS, directly subsuming this PR's entire feature

🤖 Generated with Claude Code

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch: #34432 (item 1) also makes server.emit('connection', socket) work, by moving http2's allowHTTP1 fallback listener to internal/http1_server_fallback and registering it on http.Server. The feature overlaps, the implementations differ:

Both register a 'connection' listener in the Server constructor, so they conflict on merge. Deferring to maintainers: if #34432 lands first, this can be closed or rebased down to the remaining deltas plus the adopted-socket test suite in test/js/node/http/node-http-emit-connection.test.ts.

Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts
Comment thread test/js/node/http/node-http-emit-connection.test.ts

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/node/_http_server.ts (1)

1825-1833: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Same duplicate-property-access pattern, not yet fixed here.

server.maxRequestsPerSocket is read twice in the isRequestsLimitSet condition (Line 1825) and again at Lines 1829 and 1833 — the same anti-pattern just fixed at the neighboring expect/maxHeadersCount/etc. sites in this function. Destructure once and reuse.

♻️ Proposed fix
-    const isRequestsLimitSet = typeof server.maxRequestsPerSocket === "number" && server.maxRequestsPerSocket > 0;
+    const { maxRequestsPerSocket } = server;
+    const isRequestsLimitSet = typeof maxRequestsPerSocket === "number" && maxRequestsPerSocket > 0;
 
     if (isRequestsLimitSet) {
       state.requestsCount++;
-      res.maxRequestsOnConnectionReached = server.maxRequestsPerSocket <= state.requestsCount;
+      res.maxRequestsOnConnectionReached = maxRequestsPerSocket <= state.requestsCount;
     }
 
     const { expect } = req.headers;
-    if (isRequestsLimitSet && server.maxRequestsPerSocket < state.requestsCount) {
+    if (isRequestsLimitSet && maxRequestsPerSocket < state.requestsCount) {
🤖 Prompt for 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.

In `@src/js/node/_http_server.ts` around lines 1825 - 1833, Update the
request-limit logic around isRequestsLimitSet to destructure
server.maxRequestsPerSocket once and reuse the local value in the positivity
check, res.maxRequestsOnConnectionReached assignment, and subsequent comparison;
preserve the existing behavior.
🤖 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.

Outside diff comments:
In `@src/js/node/_http_server.ts`:
- Around line 1825-1833: Update the request-limit logic around
isRequestsLimitSet to destructure server.maxRequestsPerSocket once and reuse the
local value in the positivity check, res.maxRequestsOnConnectionReached
assignment, and subsequent comparison; preserve the existing behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1e54384d-ea3f-4df0-a324-97ef4f48f5da

📥 Commits

Reviewing files that changed from the base of the PR and between 9512c49 and ad5c6a1.

📒 Files selected for processing (1)
  • src/js/node/_http_server.ts

…ding

A standalone ServerResponse (no native handle) whose header was rendered
by writeHead() but that had no socket assigned yet (a response queued
behind an in-flight pipelined response on an adopted socket) hit an
early return in end() that buffered nothing: the 400/503/417
auto-responses never reached the wire and the connection hung. Route
the no-handle path through OutgoingMessage.end unconditionally, like
Node, so assignSocket() -> _flush() writes the buffered response and
'finish' fires.

Also point NodeHTTPServerSocket#setEncoding at the shared
socketSetEncoding helper, hoist maxRequestsPerSocket, and reject
pending test waiters on socket close/error so regressions fail with
the received bytes instead of a timeout.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
test/js/node/http/node-http-emit-connection.test.ts (2)

141-151: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cover adopted CONNECT separately.

The implementation has a distinct CONNECT → connect dispatch branch; an Upgrade-only test cannot catch a wrong event selection. Add an adopted-socket CONNECT case that asserts the connect listener and forwarded head/data. As per coding guidelines, tests must cover the complete variant matrix, including sibling entry points.

🤖 Prompt for 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.

In `@test/js/node/http/node-http-emit-connection.test.ts` around lines 141 - 151,
Extend the HTTP connection event tests with a separate adopted-socket CONNECT
case alongside the existing "'upgrade' hands the adopted socket to the listener"
test. Register a "connect" listener and assert that CONNECT dispatch selects it,
forwards any initial head data, and preserves subsequent socket data handling;
do not rely on the upgrade test to cover this sibling branch.

Source: Coding guidelines


179-181: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exercise the exported listener behaviorally.

typeof http._connectionListener === "function" permits an unrelated placeholder. Invoke the export against an adopted duplex and assert request parsing/response output. As per coding guidelines, every assertion should assert the strongest invariant.

🤖 Prompt for 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.

In `@test/js/node/http/node-http-emit-connection.test.ts` around lines 179 - 181,
Replace the type-only assertion in the “http._connectionListener is exported”
test with a behavioral test that invokes http._connectionListener using an
adopted duplex, then verifies request parsing and the resulting response output.
Assert concrete response invariants rather than only checking that the export is
callable.

Source: Coding guidelines

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

Outside diff comments:
In `@test/js/node/http/node-http-emit-connection.test.ts`:
- Around line 141-151: Extend the HTTP connection event tests with a separate
adopted-socket CONNECT case alongside the existing "'upgrade' hands the adopted
socket to the listener" test. Register a "connect" listener and assert that
CONNECT dispatch selects it, forwards any initial head data, and preserves
subsequent socket data handling; do not rely on the upgrade test to cover this
sibling branch.
- Around line 179-181: Replace the type-only assertion in the
“http._connectionListener is exported” test with a behavioral test that invokes
http._connectionListener using an adopted duplex, then verifies request parsing
and the resulting response output. Assert concrete response invariants rather
than only checking that the export is callable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9c8e48ec-9a88-4c1c-867c-687f408dcb81

📥 Commits

Reviewing files that changed from the base of the PR and between ad5c6a1 and 345a2c4.

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

Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts
The upgrade branch freed the parser as soon as req.upgrade was set, but
parserOnIncoming returns 0 (no skip-body), so llhttp parses the request
body. An upgrade request whose body spans multiple data chunks was
handed off mid-body: req never completed and the remaining body bytes
leaked into the tunnel stream. Gate the handoff on req.complete, like
Node v26 (minus its early-emit UpgradeStream wrapper): llhttp pauses at
message end with the upgrade flag, the body stays on req, and the first
tunnel bytes arrive in bodyHead.

Also gate the adopted-socket 'drain' emit on writableLength === 0 to
match Node v26's socketOnDrain.

@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-emit-connection.test.ts`:
- Line 222: Update the Promise wrapping raw.listen in the test setup to accept
both resolve and reject callbacks, and attach raw.once("error", reject) before
starting the listener. Ensure listen failures reject the await instead of
leaving it pending or producing an unhandled server error.
- Line 176: Remove the duplicate connectEvent const declaration in the test
scope, retaining a single Promise declaration with the existing type and
resolver behavior so the file parses successfully.
🪄 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: 3875717c-1ae6-4410-a240-7b3d1f355cab

📥 Commits

Reviewing files that changed from the base of the PR and between 345a2c4 and b6602a3.

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

Comment thread test/js/node/http/node-http-emit-connection.test.ts Outdated
Comment thread test/js/node/http/node-http-emit-connection.test.ts Outdated
Comment thread src/js/node/_http_server.ts
Comment thread test/js/node/http/node-http-emit-connection.test.ts Outdated
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread test/js/node/http/node-http-emit-connection.test.ts
Comment thread test/js/node/http/node-http-emit-connection.test.ts
…th on handle-less responses

ServerResponse.prototype.writableLength only counted native-handle bytes,
so a queued pipelined response on an adopted socket reported 0 even with
outputData populated. OutgoingMessage.end() branches on writableLength to
decide whether 'finish' queues behind the buffered output; getting 0 sent
it through process.nextTick, firing resOnFinish before the response was
assigned its socket (assertion failure in debug, uncaught
ERR_HTTP_SOCKET_ASSIGNED in release). Fall through to the OutgoingMessage
accounting (outputSize + chunked framing + assigned socket buffer) when
there is no native handle.

Also make the bodied-upgrade test yield between writes so the two packets
arrive as separate 'data' events, and wire the remaining listen promise
to reject on error.
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
…nished on handle-less responses

Same class as the writableLength fix: both getters only consulted
native-handle state, so on adopted sockets writableNeedDrain stayed
false after a buffered write returned false, and writableFinished
reported true for a queued pipelined response with bytes still in
outputData (which also short-circuited writableLength to 0 after
end()). Fall through to the OutgoingMessage accounting when there is
no native handle. Verified both against Node v26.3.0.
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
….socket while queued

Returning 0 from parserOnIncoming for upgrades parsed the request body
into req, but with no UpgradeStream early-emit nothing drains req: a
body larger than the readable high-water mark paused the adopted socket
via parserOnBody's backpressure and the connection stalled forever
(Node serves it). Return 2 (skip body + upgrade) like Node before its
UpgradeStream: llhttp pauses at the end of the headers and body/tunnel
bytes reach the listener raw through bodyHead and 'data' events.

Also register Node's 'resume' guard so a user resume() on the adopted
duplex cannot feed data to a parser paused by the pipelining gate
(execute() on a paused llhttp surfaces HPE_PAUSED as a spurious
clientError), and make res.socket return null for a standalone response
until assignSocket() instead of fabricating a FakeSocket (the
deprecated fetch()-style response keeps it).

@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 new issues found in this pass, but this is a ~480-line port of Node's connectionListener into _http_server.ts with several subtle interactions against the existing native-handle ServerResponse path — worth a human look before merge.

What was reviewed:

  • The adopted-socket parser lifecycle (socketOnData/onParserExecuteCommon/parserOnIncoming/resOnFinish) against Node v26.3.0's lib/_http_server.js — the upgrade skip-body (return 2), onSocketResume re-pause guard, and abortIncoming/abortOutgoing on close are now in place.
  • The !this[kHandle] fallbacks on ServerResponse's socket/writableLength/writableNeedDrain/writableFinished getters — each now mirrors the OutgoingMessage getter it shadows on the standalone path.
  • The unconditional OutgoingMessagePrototype.end delegation for handle-less responses and the _headerSent term added to socketOnError's raw-400 guard — native responses still gate on headerStateSymbol alone.
  • Test file: promise rejections wired on all listen/one-shot-event waits, collect() fails waiters on socket close/error, and the upgrade-body tests now assert the skip-body semantics.
Extended reasoning...

Overview

This PR ports Node.js's connectionListener / connectionListenerInternal (~400 net lines in src/js/node/_http_server.ts) so that sockets fed into an http.Server via server.emit('connection', duplex) get an llhttp parser attached and dispatch 'request' events. It also adds !this[kHandle] fallbacks to four ServerResponse getters (socket, writableLength, writableNeedDrain, writableFinished) so the standalone/adopted-socket path reports OutgoingMessage accounting instead of native-handle sums, makes ServerResponse.prototype.end unconditionally delegate to OutgoingMessage.end when there is no native handle, exports http._connectionListener, and ships a 15-test file covering request dispatch, keep-alive, pipelining, chunked responses, upgrade/CONNECT, 100-continue, clientError, and backpressure.

Security risks

The new path parses untrusted HTTP over an adopted duplex, but it reuses the existing process.binding('http_parser') llhttp binding and _http_common freelist rather than hand-rolling parsing; header-size limits, lenient-flags, maxHeadersCount, requireHostHeader, and maxRequestsPerSocket are wired through. The socketOnError change only adds a && !message._headerSent term to the existing raw-400 guard, so it strictly narrows when a raw error response is written. No new eval/shell/fs surface.

Level of scrutiny

High. This is core node:http server compatibility code with a new JS-side request path that must interoperate with the existing native uWS ServerResponse implementation. Six prior review rounds each surfaced real bugs (a queued-response end() hang, a writableLength misreport that fired 'finish' out of order, an upgrade-body deadlock, missing onSocketResume/abortIncoming, and several native-shaped getter overrides reporting wrong values on the adopted path). All flagged threads are now resolved and the current diff addresses each, but the density of subtle path-interaction bugs found so far argues for a maintainer pass rather than auto-approval.

Other factors

  • The instanceof NodeHTTPServerSocket early-return keeps natively accepted connections on the uWS fast path, and the getter changes are all gated on !this[kHandle], so the existing hot path should be unaffected — but that invariant is worth a maintainer confirming.
  • One prior 🟡 (call resetSocketTimeout at the top of onParserExecuteCommon, not only in parserOnIncoming) is marked resolved but not applied; the residual divergence (a >6s idle gap between header bytes on an adopted keep-alive socket) is narrow enough that leaving it may be intentional.
  • Test coverage is solid and hermetic (local net.Server bridge, port: 0, failure events reject waiters, test.concurrent), and the PR body shows the suite failing on 1.4.0 and passing here.

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

🤖 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-emit-connection.test.ts`:
- Around line 147-157: Update the test setup callback around res.write and the
"drain" listener: register the drain listener before writing, and ensure
res.end() is also reached when no drain event occurs so the client closes
without relying on a timeout. Preserve the existing state tracking and final
assertions so an incorrect write() result or missing drain is reported as an
assertion failure.
🪄 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: 215733cb-8272-4f16-bf68-f51b11abf0f4

📥 Commits

Reviewing files that changed from the base of the PR and between b6602a3 and 093420c.

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

Comment thread test/js/node/http/node-http-emit-connection.test.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
…never sent 100

res._expect_continue was assigned but nothing consumed it: Node's
writeHead clears shouldKeepAlive when the client expected 100 Continue
and only a final status was sent, since the client may still put the
request body on the wire (which would desync the parser on a kept-alive
connection). Add the same gate before the standalone header render;
verified against the v26.3.0 binary (Connection: close + socket end,
where bun previously advertised keep-alive and left the socket open).

@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 new findings this pass, but this needs a human look: it's a ~470-line port of Node's connectionListener/parser plumbing plus changes to shared ServerResponse accessors (socket, writableLength/writableNeedDrain/writableFinished, the no-handle end()/writeHead() paths) that the native uWS path also flows through. There's also an overlapping open PR (#34432) taking a different approach to the same feature.

A few earlier threads were resolved without a code change I can see in the current diff — worth a spot-check by whoever merges: the queued-response res.destroy() deferral, resetSocketTimeout at the top of onParserExecuteCommon, abortIncoming in socketOnEnd, and headersTimeout/requestTimeout enforcement on adopted sockets.

Extended reasoning...

Overview

This PR ports Node v26.3.0's connectionListener / connectionListenerInternal and its socket/parser lifecycle into src/js/node/_http_server.ts (~470 net new lines) so server.emit('connection', duplex) attaches an llhttp parser and dispatches 'request' like Node. It also touches shared ServerResponse surfaces used by the native uWS path: the socket getter, writableLength/writableNeedDrain/writableFinished, the no-handle branch of end(), and the no-handle branch of writeHead() (new _expect_continue consumer). _http_outgoing.ts exports kChunkedLength; http.ts re-exports _connectionListener. A new 341-line test file covers request dispatch, keep-alive, pipelining, chunked, upgrade/CONNECT, 100-continue, clientError/400, backpressure, and the native path.

Security risks

The new path attaches a REQUEST-mode parser to arbitrary user-supplied duplex streams. Parsing goes through the existing hardened process.binding('http_parser') llhttp binding with the server's maxHeaderSize/maxHeadersCount/lenient-flag settings, and error handling routes through the pre-existing socketOnError (raw 400/431/408). I don't see new hand-rolled parsing. The main security-adjacent gap is that headersTimeout/requestTimeout are not enforced on adopted sockets (raised earlier and resolved without a visible change) — a slow-loris on an adopted duplex is not swept by checkConnections. That is a DoS-resilience difference from Node worth a maintainer decision.

Level of scrutiny

High. This is a substantial Node-compat port with many interacting edge cases (pipelining backpressure, upgrade body handling, keep-alive timeout arming, Expect: 100-continue), and it modifies getters/end() that the native fast path also reads — a regression there would affect every http.Server user, not just adopted sockets. Eight prior review rounds each surfaced real bugs that led to fix commits, which is a signal of the surface's complexity.

Other factors

  • There is an overlapping open PR (#34432) that implements the same feature by reusing the http2 allowHTTP1 fallback listener; a maintainer should decide which approach to land.
  • Several earlier inline threads are marked resolved but I can't find corresponding code in the current diff (queued res.destroy() once('socket') deferral / resOnFinish guard; resetSocketTimeout at first-byte in onParserExecuteCommon; abortIncoming(state.incoming) in socketOnEnd; headersTimeout/requestTimeout sweep). They may have been intentionally deferred as edge-case follow-ups; flagging so the merger can confirm.
  • Test coverage is thorough for the happy paths and the specific regressions caught in review; the shared-getter changes on the native path are covered only by the "server sockets accepted natively are unaffected" smoke test.

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

For whoever reviews: the four threads resolved without a code change were deliberate declines, each checked against the v26.3.0 reference rather than assumed. Summary so the rationale is in one place:

  • queued res.destroy() deferral: v26.3.0's OutgoingMessage.destroy has no once('socket') deferral (that was older Node); with no socket it only schedules emitDestroyNT. Ran the exact scenario against the v26.3.0 binary with a 9 second window: Node also keeps the connection open, identical to this port.
  • resetSocketTimeout at the top of onParserExecuteCommon: v26.3.0 calls it only from parserOnIncoming (line 1257); the port matches.
  • abortIncoming in socketOnEnd: not present in v26.3.0's socketOnEnd (removed upstream); socketOnClose still aborts in-flight requests, same as Node.
  • headersTimeout/requestTimeout on adopted sockets: the adoption-only case (server never listens) matches Node, which also creates server[kConnections] only on 'listening'. The hybrid listen-and-adopt case needs a ConnectionsList wired through parser.initialize plus a checkConnections sweep that consults it; left as a contained follow-up, and server.timeout (socket.setTimeout) already applies to adopted sockets.

On #34432: overlap acknowledged earlier in this thread; both register a connection listener in the Server constructor, so whichever lands first, the other rebases or closes.

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

CI summary for build 78798: 195 of 196 jobs passed. The one red lane (debian 13 x64) is a native crash in test-https-server-connections-checking-leak.js, a GC-finalizer test with release-only unmapped native frames. This diff is JS-only and the crash does not reproduce locally in 110 runs (100 on the main-based release canary, 10 under ASAN with this diff); it has been reported to main-break triage separately. Every other failure in the run passed on retry. The diff is ready for review.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Re-checked against current main (165dc9f) now that #35281 is closed. The issue's own scenario is covered: #34432 registers a connection listener on http.Server, and 13 of the 17 tests in this PR's test/js/node/http/node-http-emit-connection.test.ts pass on main unchanged (request dispatch, keep-alive, bodies, chunked, clientError, default 400, upgrade, CONNECT, 100-continue, native path, drain).

These 4 still fail on main, deterministically, also when run in isolation:

  • auto 400 on a pipelined request queued behind an in-flight response: the client socket is closed with nothing written (received so far: "").
  • queued pipelined response with Content-Length and write() before end(): same, nothing written.
  • rejected Expect: 100-continue closes the connection: the 401 goes out with Connection: keep-alive instead of Connection: close.
  • http._connectionListener serves a socket when invoked directly: http._connectionListener is not exported on main.

The two pipelining failures are a bug in the adopted-socket path on main: sending GET /a and GET /b in one write to a server that received the socket via emit('connection') emits clientError with ERR_HTTP_SOCKET_ASSIGNED: Socket already assigned, /b is never answered, and with an async handler even /a gets no response (Node v26.3.0 answers both; the same happens on the http2 allowHTTP1 fallback, which shares the listener). That is already being fixed on top of the current listener in #36991. The rejected 100-continue case reproduces on the native server path too and is covered by #33293. Once those two land, the only thing left from this PR that main lacks is the http._connectionListener export, so this PR as written (its own listener, now conflicting with main) is mostly superseded; keeping it open until then in case that export is still wanted.

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Re-checked on main at 731aa92. This PR stays open.

The scenario from #35281 is fixed on main by #34432, which registers a connection listener on http.Server. With this PR's test/js/node/http/node-http-emit-connection.test.ts applied on main without its src/ changes, 13 of the 17 tests pass. The same 4 still fail, in isolation and in the full file:

  • auto 400 on a pipelined request queued behind an in-flight response: the socket closes with nothing written.
  • queued pipelined response with Content-Length and write() before end(): the socket closes with nothing written.
  • rejected Expect: 100-continue closes the connection: the 401 carries Connection: keep-alive instead of Connection: close, and the socket stays open.
  • http._connectionListener serves a socket when invoked directly: http._connectionListener is undefined.

The two pipelining failures come from ERR_HTTP_SOCKET_ASSIGNED ("Socket already assigned") on the adopted-socket path. The server emits clientError and destroys the socket before the first response is written. Node v26.3.0 passes all 4 scenarios. The follow-ups named earlier, #36991 (pipelining) and #33293 (rejected 100-continue), are still open. The http._connectionListener export is covered by nothing else.

The branch now conflicts with main in src/js/node/_http_server.ts.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

http.Server cannot adopt external sockets via emit('connection')

2 participants