Skip to content

node:http: emit 'connect' for a CONNECT that is pipelined behind a pending response - #43376

Closed
robobun wants to merge 4 commits into
mainfrom
robobun/8c4fb85c/pipelined-connect-tunnel
Closed

robobun wants to merge 4 commits into
mainfrom
robobun/8c4fb85c/pipelined-connect-tunnel

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A CONNECT behind a pending response on the same connection gets 'request', not 'connect'. The client receives 200 OK for a tunnel that does not exist, its next bytes are dropped, and the server holds the socket after the client is gone.
  • Node v26.3.0 emits 'connect' at once, or destroys the socket when there is no listener.
  • Cause: the dispatcher takes the tunnel path only for method === "CONNECT" && !isPipelined (src/js/node/_http_server.ts:738). The native parser is in tunnel mode after every dispatched CONNECT (packages/bun-uws/src/HttpParser.h:1370). Found by code inspection. No issue reports it.

Fix

  • Every CONNECT goes to 'connect', or closes the connection when there is no listener. A throw from the listener is rethrown on the next tick, as for 'upgrade'.
  • upgradeToTunnel() names the response of the request (a pipelined request is not the socket's current response), and the socket remembers it. The end of the tunnel unrefs that response, and a read of the request ahead no longer makes the tunnel socket flow.
  • Entering tunnel mode resumes reads that the HTTP side paused. The CONNECT stays queued, so the connection never counts as idle, and that entry no longer holds reads (queuedResponsesHoldReads).
  • Verified: test/js/node/http/node-http-connect.test.ts, 9 new tests, all fail without the fix. Eight match Node v26.3.0. Notes: the ninth, the limits, the other suites.

Background

  • Pipelining: the next request arrives before the previous response is complete. node:http dispatches it at once and queues its response.
  • Tunnel mode (isConnectRequest): the parser passes all later bytes to the JS socket unparsed.
  • Flood prevention: the server pauses reads while response bytes are unsent or responses are queued.
Notes

Repro. One write carries GET /first and CONNECT example.com:443. The 'request' listener holds the response to /first.

events
Node v26.3.0 request GET /first, connect example.com:443, then the tunnel data
main request GET /first, request CONNECT example.com:443. The client gets two 200 OK. The tunnel bytes reach no listener
this branch same as Node

The socket hold, with no 'connect' listener and the client gone 300 ms after its write: main closes the server socket after 6013 ms (keepAliveTimeout 5 s plus the 1 s buffer). With keepAliveTimeout = 0 the socket is still in CLOSE-WAIT after 9 s, and only closeIdleConnections() frees it. This branch and Node close it at once.

Limits of this PR.

  • The CONNECT head has to arrive in one read. A head that is split across reads is never dispatched on main. node:http: dispatch a CONNECT whose header block arrives in a later read #43161 fixes that, and this PR does not touch HttpParser.h.
  • Writes from the 'connect' listener wait for socket.end() when an earlier request ran on the connection, because that dispatch leaves the Duplex corked. A kept-alive CONNECT has the same limit on main, and node:http: don't leave the server socket Duplex corked across kept-alive requests #35664 fixes it. The tests answer the tunnel with socket.end() for this reason. With the cork released in the listener, the repro gives the same bytes as Node: the 200 Connection established, then the response to /first.
  • Tunnel writes go to the socket directly, and response writes go through the uWS buffers. A tunnel write that happens while the response ahead has unsent bytes can reach the wire first, and it can then land inside the body of that response. This is a race, and main has it too: a CONNECT in the same write as a 15.7 MB response that ends in its handler gets its tunnel bytes 2.6 MB into that body (release build, 3 of 3 runs, Duplex uncorked by hand). On this branch the pipelined shape kept the order in 5 of 5 runs with the debug build. req.socket.write() during a response has the same limit (node:http: order req.socket.write() behind the corked response head #35018). The fix is one ordered write path for the connection, which is a separate change.
  • A pipelined Upgrade still goes to 'request' (Node emits 'upgrade'). Native and JS agree there, so no byte is lost. The builtin ws answers the handshake through the current response of the socket, which is the response in flight. With the gate removed in an experiment, ws throws Cannot writeHead headers after they are sent to the client. With the right response it would call HttpResponse::upgrade(), which adopts the socket while the response in flight still writes through it.
  • A tunnel has no read backpressure (pause_socket returns early in tunnel mode). That is the same for every CONNECT and Upgrade tunnel on main.
  • Node does not give the same result in every case. A response ahead that waits for 'drain' never ends on Node, because Node removes its 'drain' listener from the socket at the handoff. On Bun that response ends. After server.unref(), Node exits at the end of the tunnel while the response ahead is pending. Bun keeps the process until that response is complete, and one test pins this.

Found next to this, not fixed here. On main, a CONNECT on a kept-alive connection gets a socket that already flows, because the _dump() of the earlier request resumed it. Bytes that arrive before the 'connect' listener attaches a reader are discarded. Node sets readableFlowing = null at the handoff and buffers them. This PR fixes only the entry point that it opens: a request ahead that is dumped after the handoff. The same pause problem exists for an Upgrade with a body: the switch to tunnel mode at the end of the body does not resume reads that req.pause() stopped.

Why the pipelined dispatch returns no promise. The native dispatch tail attaches a returned promise to get_this_value(), which is the wrapper of the current response of the socket (src/runtime/server/mod.rs:1421). For a pipelined dispatch that is the response in flight. The other pipelined dispatches return nothing for the same reason. Flags::TUNNELED releases the pending-request count of the CONNECT in the same tail.

Why the queue entry stays. markDone() sets isIdle only when nodeHttpQueuedPipelinedCount is 0. A tunnel on a fresh connection is never idle, because its response never completes. The entry gives the pipelined tunnel the same property after the response ahead completes, so server.close() and closeIdleConnections() leave it open (the tests call closeIdleConnections()). The entry also keeps the response registered for the close notification. The cost is the read gate: onNodeHttpReadsResumable and replayNodeHttpPausedSpill wait for the count to reach 0, so they skip that wait in tunnel mode.

Each change has a test that fails without it.

  • Without the isConnectRequest exception in the read gate, the large-response test never reads the tunnel again.
  • Without the resume at the switch to tunnel mode, the req.pause() test does the same.
  • Without the guard on the emit, the response ahead of a listener that throws is cut after first and the connection closes. On a CONNECT that is not pipelined, main closes the connection.
  • Without the guard in IncomingMessage._read, the bytes that arrive before the listener reads are lost.
  • With handle.response?.unref() at the end of the tunnel, the unref'd server exits before the response ahead is complete.

Self-review and review. A self-review raised 13 concerns. Two needed code: reads that req.pause() left paused at the handoff, and a 'connect' listener that throws. The others were about the limits above. The review on this PR added the unref target, the read of the request ahead, the promise allocation and three test gaps. All are in.

Suites run with the debug build. node-http-connect.test.ts (and 10 reruns of the new tests), node-http.test.ts, node-http-with-ws.test.ts, node-http-server-timeouts.test.ts, node-http-req-socket-pause.test.ts, node-http-server-socket-end-drain.test.ts, node-http-server-abort-events.test.ts, node-http-backpressure.test.ts, and 57 files from test/js/node/test/parallel: test-http-connect*, test-http-after-connect, test-http-eof-on-connect, test-http-proxy.js, test-http-pipeline-*, test-http-many-ended-pipelines, test-http-pause*, test-http-keep-alive*, test-http-should-keep-alive, test-http-server-close-idle*, test-http-parser-freed-*, test-http-server-request-timeout-upgrade, test-http-upgrade-*, test-http-dump-req*, test-http-incoming-message*, test-http-abort*. All pass. One exception in node-http-connect.test.ts: under a debug build on a loaded machine, tests should run on bun can pass its 5 s limit, because its sub-suite takes 5.4 to 6.7 s. A debug build of main takes the same time. Also a 200-connection stress run under ASAN, and the repro over TLS (same bytes as Node).


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/http/node-http-connect.test.ts

…nding response

The dispatcher sent a CONNECT that arrived while an earlier response was
still pending to 'request'. The native parser was already in tunnel mode.
The client got 200 OK for a tunnel that did not exist, every later byte
had no consumer, and the server held the socket after the client was gone
(6 s by default, without limit when keepAliveTimeout is 0). Found by code
inspection. No issue reports it.

The CONNECT now goes to 'connect' in both cases, like Node.js, and the
connection closes when there is no listener.

- upgradeToTunnel() names the response of the request. A queued pipelined
  request is not the current response of the socket.
- A throw from the 'connect' listener is thrown again on the next tick, as
  for 'upgrade'. The native dispatch would end the response ahead.
- Entering tunnel mode resumes reads that the HTTP side paused, because
  resume() does nothing afterwards.
- The CONNECT stays queued, so the connection never counts as idle. In
  tunnel mode that entry no longer holds paused reads.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 1027b42a-34ac-4cb5-9608-d09f66cfe335

📥 Commits

Reviewing files that changed from the base of the PR and between 367d939 and a180a04.

📒 Files selected for processing (7)
  • src/js/internal/http.ts
  • src/js/node/_http_incoming.ts
  • src/js/node/_http_server.ts
  • src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
  • src/jsc/bindings/node/JSNodeHTTPServerSocket.h
  • src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp
  • test/js/node/http/node-http-connect.test.ts

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


Walkthrough

Changes

The HTTP server now tracks the native response used during CONNECT and upgrade handoffs. Native tunnel setup accounts for pipelined CONNECT requests when resuming reads and reaching EOF. Tests cover ordering, buffering, errors, lifecycle, and missing listeners.

CONNECT tunnel handoff

Layer / File(s) Summary
Handoff contract and read path
src/js/internal/http.ts, src/js/node/_http_incoming.ts
Adds and exports kHandoffResponse. The incoming read path uses the native response-body resume path when a handoff response is present.
Tunnel wiring and queued reads
src/js/node/_http_server.ts, src/jsc/bindings/node/JSNodeHTTPServerSocket.*
CONNECT and upgrade handling pass and retain the handoff response. Native tunnel setup resumes reads when queued CONNECT responses do not block them. EOF handling uses the recorded handoff response.
CONNECT pipelining validation
test/js/node/http/node-http-connect.test.ts
Adds coverage for pipelining, response ordering, backpressure, paused requests, buffered tunnel bytes, listener errors, response lifetime, and missing listeners.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to a180a

The CONNECT handoff behavior is consistently wired across JavaScript and native layers, with focused lifecycle and pipelining coverage; no merge-blocking issue remains.

🚥 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 identifies the main change: emitting 'connect' for a pipelined CONNECT request behind a pending response.
Description check ✅ Passed The description explains the problem, implementation, limitations, and verification results. It does not use the template headings, but it provides the required change summary and test information in …

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

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it: a node:http server has a 'connect' listener, and its 'request' listener holds the response. A raw TCP client sends GET /first and CONNECT example.com:443 in one write.

  • Node v26.3.0: request GET /first, then connect example.com:443. The bytes that the client sends next reach the 'connect' socket.
  • Bun main (1.4.3-canary, b52d513): request GET /first, then request CONNECT example.com:443. The client gets two 200 OK. The bytes that it sends next reach no listener.

The 9 new tests in test/js/node/http/node-http-connect.test.ts fail on main and pass on this branch.

Self-reviewed: 13 concerns raised, 2 needed code changes and both are in this PR (reads that req.pause() left paused at the handoff, and a 'connect' listener that throws). The other concerns were about the limits of the change, and the PR body states them.

Review on the PR: the findings about the unref target at the end of the tunnel, the read of the request ahead, the promise allocation and the test gaps are fixed in 97a2640. These findings are the same on main, are answered in their threads, and stay out of this PR:

PR: #43376

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
Comment thread test/js/node/http/node-http-connect.test.ts
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp
Comment thread src/js/node/_http_server.ts
@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:13 PM PT - Sep 18th, 2026

✅ @robobun, your commit a180a040faad6701895e521d10f97fe4d6c61a92 passed in Build #118040! 🎉


🧪   To try this PR locally:

bunx bun-pr 43376

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

bun-43376 --bun

…tunnel's way

- The socket remembers the response that was handed off. The end of the
  tunnel unrefs that response. It used the current response of the socket,
  which behind a pipelined CONNECT is the response in flight, so a process
  that only that response kept alive could exit before it was complete.
- A read of a request on a socket that was handed off no longer makes the
  socket flow. res.end() dumps the request ahead of the CONNECT after the
  handoff, and a socket that flows without a reader discards tunnel bytes.
- The handoff promise is created only on the path that returns it.
- Tests: a 'connect' listener that throws on a CONNECT that is not
  pipelined, bytes that arrive before the listener reads the socket, the
  end of the tunnel on an unref'd server, and failure events that reject
  the wait of the shared helper.
Comment thread src/js/internal/http.ts Outdated
Comment thread src/js/node/_http_incoming.ts Outdated
Comment thread src/js/node/_http_incoming.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.h Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp Outdated
Comment thread src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp 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.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js/node/_http_server.ts — pre-existing: a 'request' listener that throws on a pipelined request cuts off the response ahead of it, on this branch as on the base, while Node leaves that response intact. The new try/catch at _http_server.ts:762-767 guards only the CONNECT emit; the sibling pipelined emits at _http_server.ts:1028, 1031, 1034 and 1040 still let the throw reach mod.rs:1467-1478, which ends the socket's current uws response, the in-flight one, not the thrown request's. Fix: gate the native end at mod.rs:1476 on the nhr being the socket's current response (skip it for a pipelined dispatch and destroy that request's queued ServerResponse instead), which covers all 5 emit sites, rather than a per-emit try/catch. Same pattern at 5 sites (_http_server.ts:763, 1028, 1031, 1034, 1040).

    Extended reasoning...

    Trigger: a server with process.on('uncaughtException') installed (common in production to log and keep serving); a client pipelines GET /a and GET /b in one write; /a's handler keeps its response open; /b's 'request' listener throws synchronously. On Node the throw is an uncaught exception and /a's response continues unharmed. On Bun the throw leaves server.emit('request') at _http_server.ts:1040 uncaught, unwinds NodeHTTPServer__onRequest (NodeHTTP.cpp:367-368), and on_node_http_request classifies it as HttpResult::Exception at mod.rs:1370-1371. The tail at mod.rs:1463-1478 reads nhr.raw_response, which for a pipelined dispatch is the same socket as the in-flight response, so raw.state().is_response_pending() is true because of /a. It then calls raw.write_status(b"500 Internal Server Error") + raw.end_without_body(true) or raw.end_stream(true) on that response, ending /a's response mid-body (chunked terminator or truncated Content-Length body). mod.rs:1488-1489 then marks /b's nhr ENDED and completes it, while /b's ServerResponse stays in socket[kPipelinedResponses]; advanceResponsePipeline…

    Verification: pre-existing — the base already fails by the same route; this PR touches the same dispatcher (adds a try/catch only around the CONNECT emit at /home/claude/bun/src/js/node/_http_server.ts:762-767) but leaves the sibling emits and the native tail unchanged. Trigger: a client pipelines two requests in one segment, the first handler keeps its response open, and the 'request' listener (or…

Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

About the additional finding (a 'request' listener that throws for a pipelined request cuts off the response ahead): confirmed on main. Two GETs in one write, the first response pending, the second listener throws: Node delivers first-done and keeps the connection, main delivers first and closes. It is the same native tail as the CONNECT case (mod.rs, the Exception arm ends the socket's current uWS response). The gate you describe in mod.rs is the better fix, because it covers all emit sites. It changes every pipelined dispatch, so it is a separate change from this PR.

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

Code review found no issues

No high-confidence issues detected in this change.

Jarred-Sumner added a commit that referenced this pull request Sep 26, 2026
…inish, lifecycle) (#43557)

One pull request for the open `node:http` server pull requests. Each
root cause is fixed once, and each pull request's tests are carried
over. Node is the reference: every scenario was run under Node and under
Bun from one script, and the outputs were compared.

Fixes #4733
Fixes #18613
Fixes #40350
Fixes #43155
Fixes #43297
Fixes #43513
Fixes #43527
Fixes #25632
Fixes #31301
Fixes #43027
Fixes #43163
Fixes #43342
Fixes #43344
Fixes #43370
Fixes #43490
Fixes #43512
Fixes #43519

Each of these has a repro that is wrong on Bun 1.4.3, right on this
branch, and the same as Node.

| Issue | Not closed by this PR, because |
| --- | --- |
| #30501 (msal-node keeps Bun alive at exit) | Probably fixed. The repro
copies the teardown of msal-node. The package itself was not run. |
| #14430 (yarn: "does not support SSL") | Probably fixed.
`response.hasOwnProperty("socket")` is now `true`. yarn itself was not
run. |
| #39681 (`server.setTimeout` callback after destroy) | Probably fixed.
The repro is the deterministic case of #39686. The script in the issue
depends on timing and on Windows. |
| #43455 (`req.complete`, nine flows) | Partially addressed. Flows 1, 2,
3, 5 and 7 are fixed, and flow 6 was already right. Flow 8 (a socket
timeout while the body of an Upgrade request arrives) and flow 9 (a
request that `stream.pipeline()` destroyed never reports `complete`) are
not. Flow 4 differs only in `_readableState.ended`. |

### What changes for users

| Area | Before | After (same as Node) |
| --- | --- | --- |
| `req.pause()` | The socket stops at once. `req.complete` stays `false`
for a small body. | The body is received until the buffer is full. Then
the socket stops. |
| `res.end()` before the body arrives | `req` gets `'end'` and `'close'`
at once, and the body is lost | The request completes when its body
really ends |
| `res.destroy()` in the middle of a body | `'end'` with bytes missing |
`aborted`, then `ECONNRESET` |
| `socket.destroy()` inside the `'request'` listener | The body that
came with the head is dropped | That body is still delivered |
| `'finish'` and the `end()` callback | Fire when `end()` buffers the
bytes | Fire when the last bytes have left the socket |
| A response that closes the connection | The server half-closes and
waits for the peer | The socket closes right behind the FIN |
| `'drain'` after a later write flushed the backlog | Lost. `pipe(res)`
could hang. | Emitted |
| A pipelined request whose body continues after the previous response
ends | Body dropped, no response, `server.close()` hangs | Delivered |
| CONNECT and Upgrade tunnel sockets | Keep reading when paused or full
| Stop reading. `_read()` starts them again. |
| A tunnel write that waits for a drain when the client goes away | Its
callback, the callbacks of the writes behind it and the `end()` callback
never run | They run with an error before `'close'` |
| Upgrade request with a body, paused in its listener | The body flows
away | The request keeps its body |
| `ws` on a reused keep-alive socket | Writes after the Upgrade could
stall | Sent |
| A raw `socket.write()` behind a response that still drains (the 400
for a bad pipelined request, the reply of a `'clientError'` listener) |
Lands in the middle of that response | Sent after it |
| `server.close()` | Could report closed while connections were open |
Waits for every connection. An idle tunnel does not keep the process
alive. |
| `closeAllConnections()` on a listening server | Also stops the
listener and destroys tunnels and WebSockets | Destroys only the HTTP
connections |
| `Proxy-Connection: close` (node:http only) | Ignored. The connection
stays open. | Ends the connection, like `Connection: close` |
| A response larger than 16 KB, also in `Bun.serve` and over TLS | Up to
4 `send()` calls for each chunk. Slower than Node in most cases. | One
write for the writes of one tick. 1.1x to 2.7x the requests per second
of main, and faster than Node. |
| `socket.destroy()` and then `res.end()` in a listener | (this PR,
earlier) `req` ended as if it were complete | `'aborted'`, then
`ECONNRESET` |
| `emit('connection')` or http2 `allowHTTP1`: the response ends while
the listener still reads the body | The rest of the body is dropped |
The body is complete |
| `httpValidation: "relaxed"`, `Content-Length` or `Transfer-Encoding`
in trailers | Accepted | `HPE_INVALID_CONTENT_LENGTH`,
`HPE_INVALID_TRANSFER_ENCODING` |
| `req.complete` inside `'connect'`, and inside `'upgrade'` without a
body | `false` | `true` |
| `optimizeEmptyRequests`: `socket.parser.incoming` after the response |
Keeps the request alive on an idle connection | `null` |
| A HEAD or OPTIONS request with `Content-Length` | The body is dropped,
and `req.complete` is `true` before it comes | The request has its body
|
| `res.end(chunk)` after the client went away | `finished` and
`writableEnded` stay `false`, no `'prefinish'` | The response ends |
| An HTTP/1.0 request with an `Expect` header | `100 Continue`,
`'checkContinue'`, `'checkExpectation'` or a 417 | A plain `'request'` |
| The idle sweep of `close()` and `closeIdleConnections()` | Could
destroy a connection that was still receiving a request, or whose
response was still draining | Closes only idle connections |

### Design

| Piece | What it is |
| --- | --- |
| Request body state | `None / Pending / Complete / Aborted / Upgraded /
Detached`. Only the last chunk sets `Complete`. One function,
`leave_pending`, is the only other way out of `Pending`. |
| Read flow control | One path: `push()` returning false stops the
socket, `_read()` starts it. Both native pause buffers are removed: no
read is copied and replayed. |
| "This read is parsed" signal | `notifyWhenReadParsed()` sets a uws
state bit. uws delivers a `readParsed` event after the read. It replaces
a `setImmediate`. |
| Close during a parse | One uws bit defers a close to the end of the
current message. |
| Response finish | A response is finished when it has ended and the
socket has fully drained. |
| Idle connection | One rule, `HttpResponse::closeIfIdle()`. A
connection is idle when it receives no request (head or body) and no
response is in flight, queued or undrained. The sweep of `close()` and
`closeIdleConnections()` both use it. |
| Idle tunnel | A tunnel at read EOF with nothing left to send. uws
reports it to the server through the connection filter (`-3`, `+3`,
`-4`). It still counts for `'close'`, but it does not hold the event
loop, like a libuv handle in that state. |
| Server `'close'` | One native close promise per `listen()`. `close()`
records whether its sweep left nothing open. Then a `listen()` in the
same tick cannot hold `'close'` back, as in
`net.Server._emitCloseIfDrained`. |
| Raw socket writes | While uws holds response bytes (its buffer, the
zero-copy tail of a `res.write()`, the cork buffer), a raw write goes
through `AsyncSocket::write`, the path a 1xx line takes. So the order on
the wire is the order of the calls. |
| Upgrade verdict | One scanner and one verdict, shared by the parser
and the dispatcher. |
| llhttp | Updated from 9.3.0 to 9.4.2, as Node v26.5.0 vendors it, plus
one local patch (see below). Node v26.5.1 and later vendor 9.4.3. That
update is not in this PR. |

The parser changes also tighten request framing so that it agrees with
llhttp in more cases. There is no new API surface.

### A pause holds from the next read

The copy of the rest of a read (`nodeHttpPausedSpill`), its replay from
a posted task and the nested parse are removed. Like in Node, the rest
of the read that caused a pause is still parsed, and the socket stops at
the next read. usockets reads up to 512 KB in one call. libuv reads 64
KB.

| One paused, unread request (client sends 64 MB) | Bytes held |
| --- | --- |
| Node 25.6 | 131,018 |
| This pull request | 524,234 |

The price is in one case. A client sends 512 KB of small pipelined
requests (19,418 of them) and never reads. Each handler answers with its
own 64 KB body:

| Handler | Runtime | Requests dispatched | RSS |
| --- | --- | --- | --- |
| Answers at once | Node 26.3 | 2,425 | +177 MB |
| Answers at once | main | 41 | +9 MB |
| Answers at once | This PR | 2,425 | +171 MB |
| Answers one tick later | Node 26.3 | 4,850 | +336 MB |
| Answers one tick later | main | 2,426 | +181 MB |
| Answers one tick later | This PR | 4,850 | +330 MB |

Release builds on Linux x64. This PR now does what Node does. main held
fewer responses, mostly for a handler that answers at once. On macOS one
read can return all 512 KB. There, Bun 1.4.3 already reached +951 MB for
the handler that answers one tick later, and Node reached +1,294 MB.
`server.maxRequestsPerSocket` bounds it.

### Performance

#### Responses larger than 16 KB are faster, and now faster than Node

On main, a response that did not fit the 16 KB uWS cork buffer released
the cork. After that, each piece was its own `send()`: the buffered
head, the chunk-size line, the data, the `\r\n` and the last chunk. Over
TLS, each 2-byte piece was also its own record. Two changes fix that,
for `Bun.serve` and for node:http:

| Change | Effect |
| --- | --- |
| A write that does not fit goes out with the cork buffer and its
framing in one vectored write | No copy is added. Over TLS, the records
of all the pieces share the write batch that one `SSL_write` loop
already had. |
| The cork buffer holds 128 KB, up from 16 KB. Only a write of 16 KB or
less is copied into it, as before. | Several writes in one tick go out
in one write, like in Node. A longer write still goes out without a
copy. |

The bytes on the wire are the same. The vectored write uses `sendmsg()`
with the flags that `send()` uses.

Write syscalls for one response:

| Response | Node 26.3 | main | This PR |
| --- | --- | --- | --- |
| 4 x `res.write(16 KB)` | 1 | 16 | 1 |
| 40 x `res.write(2 KB)` | 1 | 16 | 1 |
| `res.end(64 KB)` | 1 | 2 | 1 |
| 256 KB file, `.pipe(res)` | 4 | 16 | 5 |

Throughput (req/s, the mean of 2 rounds). Node v26.3.0, main
`97246d044e`, this PR `fe0ed1fbea`, with the method below:

| Case | Node | main | This PR | main / Node | PR / Node | PR / main |
| --- | --- | --- | --- | --- | --- | --- |
| http, 4 x `res.write(16 KB)` | 17,735 | 6,983 | 19,160 | 0.39x | 1.08x
| 2.74x |
| https, 4 x `res.write(16 KB)` | 11,720 | 6,110 | 15,510 | 0.52x |
1.32x | 2.54x |
| http, 40 x `res.write(2 KB)` | 9,879 | 6,140 | 14,980 | 0.62x | 1.52x
| 2.44x |
| https, 40 x `res.write(2 KB)` | 6,981 | 5,541 | 11,780 | 0.79x | 1.69x
| 2.13x |
| http, `res.end(64 KB)` | 18,535 | 16,528 | 19,889 | 0.89x | 1.07x |
1.20x |
| http, 256 KB file `.pipe(res)` | 2,684 | 2,375 | 2,719 | 0.88x | 1.01x
| 1.15x |
| https, `res.end(64 KB)` | 11,894 | 14,586 | 16,426 | 1.23x | 1.38x |
1.13x |
| http, GET hello (control) | 55,994 | 70,989 | 71,958 | 1.27x | 1.29x |
1.01x |

main was slower than Node in six of these eight cases. This PR is faster
than Node in all eight.

`Bun.serve`, measured on `a771572a8d`, before the larger cork buffer
(req/s, the mean of 2 rounds):

| Case | main | PR | Change |
| --- | --- | --- | --- |
| Direct stream, 4 x 16 KB | 6,826 | 12,479 | +83% |
| 64 KB string | 16,944 | 20,145 | +19% |
| TLS, 64 KB string | 15,045 | 16,892 | +12% |
| hello (control) | 83,957 | 83,137 | -1.0% |

These runs are on loopback, where the kernel send buffer is 2.6 MB and
the work of the receiver runs inside `send()`. That is the best case for
fewer writes. A new connection over a real network takes about 46 KB in
its first write on Linux. The rest waits in the socket buffer, as it
would after separate writes.

#### Small responses are unchanged

A small response is already one `recvfrom` and one `sendto` on both
builds. `perf` puts 66% of the time of a hello-world server in the
kernel, on both builds.

CI release builds on Linux x64: main `97246d044e` (the merge base)
against this PR `8834cd0787`. Both use the same WebKit. The server runs
on one pinned core. `oha` sends 64 connections for 5 s after a 2 s
warm-up. There are 2 rounds, and the order of the builds alternates.
"Change" compares the means of the two rounds.

Framework servers from `bun-perf-tester` (req/s):

| Server | main, round 1 | main, round 2 | PR, round 1 | PR, round 2 |
Change |
| --- | --- | --- | --- | --- | --- |
| express | 49,803 | 51,103 | 49,968 | 50,263 | -0.7% |
| fastify | 61,214 | 61,105 | 60,705 | 60,668 | -0.8% |
| node:http | 70,934 | 71,405 | 73,178 | 71,053 | +1.3% |
| elysia | 84,696 | 85,096 | 85,027 | 84,882 | +0.1% |
| `Bun.serve` | 89,020 | 89,099 | 88,168 | 88,373 | -0.9% |

node:http paths that this PR changes (req/s):

| Case | main, round 1 | main, round 2 | PR, round 1 | PR, round 2 |
Change |
| --- | --- | --- | --- | --- | --- |
| GET hello | 70,226 | 70,341 | 70,151 | 72,359 | +1.4% |
| POST, 16 KB body | 47,446 | 47,789 | 48,191 | 48,785 | +1.8% |
| 64 KB response in four writes | 6,885 | 6,894 | 6,834 | 6,868 | -0.6%
|
| Pipelined keep-alive, depth 8 | 94,063 | 93,294 | 92,909 | 93,809 |
-0.3% |

p99 latency (ms), the higher of the two rounds:

| Server | main | PR |
| --- | --- | --- |
| express | 1.94 | 1.91 |
| fastify | 1.52 | 1.55 |
| node:http | 1.11 | 1.12 |
| elysia | 0.98 | 0.96 |
| `Bun.serve` | 0.82 | 0.83 |

RSS (MB), one pass of 8 s of load:

| Server | Build | Start | Under load | 5 s idle | 15 s idle |
| --- | --- | --- | --- | --- | --- |
| express | main | 39 | 94 | 61 | 57 |
| express | PR | 40 | 92 | 60 | 57 |
| fastify | main | 41 | 91 | 58 | 55 |
| fastify | PR | 41 | 91 | 58 | 55 |
| node:http | main | 20 | 64 | 43 | 40 |
| node:http | PR | 20 | 65 | 45 | 41 |
| elysia | main | 28 | 46 | 36 | 35 |
| elysia | PR | 29 | 46 | 37 | 36 |
| `Bun.serve` | main | 14 | 30 | 22 | 22 |
| `Bun.serve` | PR | 14 | 30 | 22 | 22 |

Every change is within 2%. fastify and `Bun.serve` hello are lower in
both rounds, by about 1%. `Bun.serve` hello shows the same -1.0% in the
control row above, so a small real cost there is possible. The RSS pass
ran at the same time as the throughput runs, on other cores. The commits
after `8834cd0787` change tests and add one version check to the
node:http dispatcher. They were not measured.

### Supersedes

| Theme | Pull requests |
| --- | --- |
| Request body | #43592 #43579 #38196 #43518 #43602 #43408 #43427 #43597
#43555 #43456 #43466 |
| Tunnels | #43570 #43485. #43596 is a duplicate of #43570. |
| Parser | #43182 #43161 #43326 #43327 #40505 #43363 #42532 #42194 |
| Response write | #39386 #43371 #43548 #43499 #43496 #43464 #42008
#43549 |
| Response finish | #40351 #43021 #41822 #43473 #42068 #35207 #43425
#43503 |
| Lifecycle | #43413 #39686 #43028 #42727 #42622 #42610 #35837 #35839
#37825 #37749 #43376 #35268 |
| JS API | #41691 #41738 #38036 #42462 #36527 #39718 #37964. #42947
merged on its own. |

The close drain, `resetAndDestroy()`, the pending write callback
handling and the response `'close'` ordering come from #42622 and #42727
by @steipete. The diagnosis and the tests for the stalled `ws` writes
come from his #42610.

Not included:

| Pull request | Reason |
| --- | --- |
| #33061 | main already enforces `headersTimeout` and `requestTimeout` |
| #41672 | It makes `http.createServer({ key, cert })` stop serving TLS.
That needs a product decision. |
| #37543 | A type refactor with no tests and no user-visible change |
| #35465 | It makes `http.Server` extend `net.Server`. Only the
prototype chains were joined. The `net.Server` constructor never ran, so
`_handle` and `_connections` were `undefined`, and
`_emitCloseIfDrained()` emitted `'close'` on a listening server. The
server is backed by uWS, not `node:net`. |
| The `AutoFlusher` removal in #42622 | It makes `flushHeaders()` flush
at once. That is a performance change with no relation to the rest. |

### Tests

| Check | Result on a debug build (macOS arm64) | Head |
| --- | --- | --- |
| Every test file that this PR touches (28 files) | 1,866 pass, 2 fail.
The 2 failures are `serve.test.ts` "bounds memory when proxying ... to a
stalled client". They fail the same way on a debug build of main. |
`83af4da4a3`, run before the last commit of main came in |
| `test/js/third_party/express` (9 files) and the `body-parser` test |
299 pass, 0 fail | `83af4da4a3`, run before the last commit of main came
in |
| Node 25.6 against Bun, 32 scenarios from two scripts (event order,
framing, lifecycle) | No regression against Bun 1.4.3 | `0ff1a23f61` |
| Every vendored Node `test-http-*` and `test-https-*` file, plus the
`test-net-*` and `test-tls-*` files for pause, write, end and close |
535 of 537 exit 0. `test-http-agent-keepalive.js` and
`test-https-timeout.js` fail on that debug build. Both pass on every CI
lane. | `daee05fcfd` (before the rebase) |
| The tests that depend on what the kernel takes in one send, on Windows
Server 2019 x64 and Windows 11 arm64 | pass | `3eef223328` (x64),
`a29289bcc1` (arm64) |
| CI build 120191 (Linux, macOS and Windows, release and ASAN) | every
lane passed | `daee05fcfd` (before the rebase) |

Each new test fails on Bun 1.4.3, or on the commit before its fix for a
fault that this branch introduced.

The two tests over the limit are `node-http-connect.test.ts` ("tests
should run on bun") and `node-http-syscall-fault.test.ts` ("racing a
queued drain"). Each starts a debug subprocess that needs more than 5 s
on this machine. Both pass on CI.

### Changes in the last push

The branch is rebased on main (`daee05fcfd` was the head before). It is
now linear.

Four regressions against main, each with a test that fails without its
fix:

| Case | main | Before this push | Now (same as Node) |
| --- | --- | --- | --- |
| `emit('connection')` or http2 `allowHTTP1`: `res.end()` on a request
that nobody reads | `'end'`, `'close'` | No events | `'end'`, `'close'`
|
| The same server, an unread 32 MB body | 0 bytes held | 32 MB held | 0
bytes held |
| A NUL in a header value with `httpValidation: "relaxed"` (client,
`HTTPParser`, `emit('connection')` server) | Accepted | The process
spins forever | `HPE_INVALID_HEADER_TOKEN` |
| `Connection: close`, body in the same read as the head, a 20 KB
response before the body is read | `'end'` with an empty body | No
events on `req` | `'end'` with the body, `'close'` |
| An empty line on an idle keep-alive connection, then `server.close()`
| 0 s | About 6 s | 0 s |

| Fix | Where |
| --- | --- |
| The finish listener of a fallback connection dumps an unread request,
like Node's `resOnFinish` | `http1_server_fallback.ts` |
| llhttp patch: `llhttp__internal__c_test_lenient_flags_20` is false for
a NUL. The relaxed state does not consume a NUL, and the next state sent
it back there. 9.4.3 has the same loop. | `llhttp.c`, noted in its
`README.md` |
| A node:http socket that `onData` is parsing gets the close gate of
`onData`, also when a large write released the cork | `HttpResponse.h`
`uncorkCompletedResponse()` |
| A read that starts no message leaves an idle connection idle |
`HttpContext.h` `onData` |

The open review threads are fixed in `8834cd0787`:
`closeAllConnections()`, `Proxy-Connection: close`, five comments cut to
one line, and the test of two overlapping listeners, which now waits on
events. With the generation gate in `emitCloseServer` removed, that test
fails in both cases. `AsyncSocketData` keeps its bools together, which
takes it from 56 to 48 bytes per socket.

`http.Server` no longer extends `net.Server` (see "Not included"). The
special case for it in `Ipc.ts` is gone too. `child.send(msg,
httpServer)` still throws `ERR_INVALID_HANDLE_TYPE`, and its test stays.

<details><summary>Changes since the first revision (2988a61)</summary>

Merged with main at `c8e1f6fa5b`. The one conflict was #43708
(`req.socket` emits `'end'` and `'error'`). Its state bit
`HTTP_NODE_PEER_ENDED` moved to bit 22, because bit 19 is
`HTTP_NODE_NOTIFY_READ_PARSED` here. Its 15 tests run in
`node-http-server-abort-events.test.ts` next to the tests of this branch
(103 pass).

CI on `2988a610c` had ten red tests from four causes. They are fixed:
- `ed882e5e99`: `write()` to a response without a body (HEAD, 204) does
not wait for unsent bytes.
- `811f817704`: an idle tunnel does not hold the event loop after
`server.close()`. Four vendored Node tests timed out on every platform.
- `0697deec11`, `d428824c08`, `7aec062d14`: the write callback tests use
a body that backs up a loopback socket, and accept what Winsock does.
- `c7af1c1615`: two tests from main asserted the old `close()` contract.

Review findings, each reproduced against Node v26.3.0 and fixed with a
test that fails without the fix:
- `d4d2b783ea`, `2cc79939bb`, `45141abca9`, `9a63dbc470`, `80a23a1822`:
the idle rule. A keep-alive connection is idle again when its body ends
after its response. A connection that owes a queued pipelined response,
that still receives a request head or body, or whose response still
drains is not idle.
- `87d8904aa3`, `7eca4f7806`: `close(cb)` followed by `listen()` in the
same tick reports `'close'`, also for an https server whose only
connection was idle.
- `3fd7b25382`: a paused pipelined request behind a response that still
drains stops the connection. The first revision read 512 MiB of 512 MiB
into memory.
- `2d1a5e2d8d`, `33e4683a8a`, `539cb41eb9`, `197c7dfc29`, `0490e3540b`:
raw socket writes stay behind every unsent response byte. The cases were
a CONNECT pipelined behind a response that still drains (its `200`
landed at offset 2.6 MB of a 64 MiB body), a zero-length tunnel write
(it hung the tunnel), the 400 replies above, the zero-copy tail of a
large `res.write()`, the cork buffer, and Windows 11, where the kernel
takes the whole response and refuses the next send.
- `0abd39c133`: an upgrade from the request's `'end'` listener keeps the
body bytes out of the WebSocket. The connection closed with 1006 right
after the 101.
- `5aafc61aa6`: the callback of a small `res.write()` that the kernel
refuses at the uncork runs on the drain. Reproduced on Windows 11 only.
- `a29289bcc1`: a tunnel write that waits for a drain settles its
callbacks when the connection closes.

Known differences from Node that this PR leaves:
- A handler that calls `res.end()` and then `server.close()` closes its
keep-alive connection at once. Node waits for the `keepAliveTimeout`.
- A pipelined Upgrade behind a response that still drains is served as a
plain request.
- An `end()` on a tunnel with no write pending, while the response
before the CONNECT still drains, closes both directions after the flush.
Node half-closes.
- A raw `req.socket.write(big)` and `req.socket.end()` with no
`res.end()` sends every byte but no FIN. main loses bytes here.
- A CONNECT socket that is given back with `server.emit('connection',
socket)` answers only the first of several pipelined requests. main
answers none.
- A large write from an `'upgrade'` listener stalls while the body of
that Upgrade request is still pending. A second `listen()` on a
listening server does not throw. Both are the same on main.
- A tunnel write that fails because the client went away fails its
callbacks but emits no `'error'`. Node emits `ECONNRESET`. A new
`'error'` could end a process that has no listener for it.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 8 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/web/fetch/fetch.stream.test.ts, test/js/node/url/url.test.ts,
test/js/node/tls/tls-syscall-fault.test.ts,
test/js/node/net/node-net-server.test.ts,
test/js/node/http/node-http.test.ts,
test/js/node/http/node-http-syscall-fault.test.ts,
test/js/node/http/node-http-server-close-drain.test.ts,
test/js/node/http/node-http-connect.test.ts,
test/js/node/http/node-http-backpressure.test.ts,
test/js/node/child_process/child_process_ipc_handle.test.ts,
test/js/bun/http/serve.test.ts,
test/js/bun/http/serve-syscall-fault.test.ts,
test/js/bun/http/bun-server.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Superseded by #43557, which is merged (5d5f03f). It fixes this once for the whole node:http server and carries the tests over.

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