node:http: hand off upgrade socket to userland#30664
Conversation
|
Updated 4:00 PM PT - May 14th, 2026
❌ @robobun, your commit a1482b1 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 30664That installs a local version of the PR into your bun-30664 --bun |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe PR gates server "upgrade" handling on present listeners and adds a native ChangesHTTP Upgrade Listener-Gating and Raw Mode Support
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
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`:
- Around line 633-635: The upgrade event currently emits kEmptyBuffer, dropping
any bytes already read past the header terminator; change the server.emit call
that currently does server.emit("upgrade", http_req, socket, kEmptyBuffer) to
pass the parser head buffer (the buffer containing bytes read past the header
terminator) as the third argument so upgrade listeners receive those pipelined
bytes; ensure the same variable used to represent the parser head earlier in the
request parsing path is supplied so ws.handleUpgrade() and other listeners get
the correct head instead of an empty buffer.
🪄 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: 4c743fe7-d65f-426e-9253-cace80c36a03
📒 Files selected for processing (3)
src/js/node/_http_server.tssrc/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpptest/js/node/http/node-http-with-ws.test.ts
|
Found 8 issues this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
CodeRabbit (#30664 review): the CONNECT path forwards pipelined data past the header terminator via the 3rd `head` arg, but the upgrade path I added hardcoded `kEmptyBuffer`. Matches Node semantics and lets `ws.handleUpgrade` feed a first WebSocket frame piggy-backed onto the handshake into the new stream instead of losing it. New test pipelines a WebSocket frame immediately after the Upgrade header terminator and asserts the listener sees exactly those bytes as `head`.
There was a problem hiding this comment.
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-with-ws.test.ts`:
- Around line 231-240: The TLS regression test should mirror the plain-upgrade
invariants: after awaiting handshakeDone assert the handshake starts with
"HTTP/1.1 101 Switching Protocols\r\n" and contains the Sec-WebSocket-Accept
header, also assert there is only one HTTP status line in the handshake (same
invariant used in the plain test), then write/read the same websocket frame and
await serverGotData and assert Buffer.concat(serverReceived) equals the sent
buffer, and finally await the upgrade-socket close promise (as the plain test
does) before calling client.end() so the TLS test covers the same upgrade/close
surface.
🪄 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: c6ce45c7-5f9f-4d82-97db-6d0598b84270
📒 Files selected for processing (1)
test/js/node/http/node-http-with-ws.test.ts
|
🟡 Diff is green — CI failures are all flake on lanes my diff does not touch:
None touch node:http or the socket handoff. Already spent my one re-roll in e4d25ee; not pushing another |
`server.on('upgrade', (req, socket) => socket.write(response))` was a silent
no-op: the 101 handshake never left the server, the client timed out, and
rsbuild (bundled `ws`) surfaced that as 'HMR broken'. Same path hits vite,
webpack-dev-server, http-proxy, socket.io — anything that writes the
switching-protocols line to the socket itself.
Two things were broken:
1. `NodeHTTPServerSocket._write` only calls `handle.write()` when
`handle.ondrain` is truthy. The upgrade path fell through `kEnableStreaming(false)`
(from the non-CONNECT branch just above) so `ondrain` was undefined and every
write became a no-op with a successful callback.
2. uWS kept parsing inbound bytes as HTTP even after the event fired, so
post-upgrade traffic (WebSocket frames, tunnel bytes) was either dropped or
answered with 400 Bad Request.
Mirror the CONNECT handoff: turn on bidirectional streaming, call a new
`socketHandle.markAsRawMode()` that sets `HttpResponseData::isConnectRequest = true`
(the same flag CONNECT flips to switch uWS into pass-through), and return a
promise that settles when the socket closes instead of falling through to
`socket.cork()` + the HTTP response lifecycle. Preserves the no-listener path
— falls through to the 'request' event as Node does.
Fixes #30661. Related: #9882, #18945, #14522, #18569.
CodeRabbit (#30664 review): the CONNECT path forwards pipelined data past the header terminator via the 3rd `head` arg, but the upgrade path I added hardcoded `kEmptyBuffer`. Matches Node semantics and lets `ws.handleUpgrade` feed a first WebSocket frame piggy-backed onto the handshake into the new stream instead of losing it. New test pipelines a WebSocket frame immediately after the Upgrade header terminator and asserts the listener sees exactly those bytes as `head`.
Per CodeRabbit review: the TLS test was checking the handshake + data-flow but missed the "only one HTTP status line" assertion and didn't await socket close on the server side. Now matches the plain-TCP test one for one.
Per claude[bot] review: when a request carries an `Upgrade:` header but the
server has no `'upgrade'` listener, my listener-gate makes control fall
through to the `'request'` event — but the skip guard at line 604 still
keyed on raw `is_upgrade`, so `http_res.socket` was never bound to the
real `NodeHTTPServerSocket`. The `ServerResponse` socket getter lazily
manufactures a `FakeSocket`, which (a) reports the wrong
`remoteAddress`, (b) never emits the `'socket'` event on the response,
(c) breaks timeout/close wiring, and (d) silently drops
`writeContinue()` / `writeEarlyHints()`.
Compute `handleAsUpgrade = is_upgrade && server.listenerCount('upgrade') > 0`
once and gate both the skip and the branch on it.
New test fires an `Upgrade: h2c` request against a server with no upgrade
listener and asserts `res.socket === req.socket` and a real
`remoteAddress` — fails on the previous two commits, passes here.
CI on 6306eef hit flaky failures on Windows lanes unrelated to this diff: - test-http-should-emit-close-when-connection-is-aborted.ts timeout on Windows 2019 (this test doesn't touch the upgrade path I changed — `handleAsUpgrade` is false for a plain `fetch()`, so the `assignSocket` branch is identical to pre-PR behavior) - hot.test.ts `reloadCounter expected 50 got 40` (Windows 11 aarch64) - bun-install-lifecycle-scripts.test.ts CPU-time assertion (flaky, retried once already) - bun-install-registry.test.ts hoisting/registry version flake None are HTTP-upgrade or node:http related. Re-rolling CI.
e4d25ee to
a1482b1
Compare
|
Still all flake, still green on my diff: Build #54350 (a1482b1, after rebase) → one failure: It is also failing on 18 other PRs in the same CI window (builds #54340–#54357: Needs a maintainer to either merge this or fix the keepalive test. Not pushing another retrigger — already spent one in e4d25ee. |
Fixes #30661. Supersedes #28158 / #27237 (both stalled and conflicted against current main).
Reproduction
This bites every
ws-based stack — rsbuild, vite, webpack-dev-server, http-proxy, socket.io — because they all write the switching-protocols line to the socket themselves. Bun's built-inwsshim usesserver.upgrade()directly and sidesteps it, which is whyWebSocketServer({ server })appeared to work.Root cause
Two bugs stacked on top of each other:
NodeHTTPServerSocket._writegates onhandle.ondrain. The upgrade path fell throughsocket[kEnableStreaming](false)(line 570) just before theis_upgradebranch, soondrainwas undefined. Every write became a silent no-op with a successful callback — the handshake never reached the socket.uWS kept owning the connection as an HTTP stream. Inbound bytes after the 101 were parsed as new HTTP requests and answered with 400 Bad Request (or dropped). Only
CONNECTflippedHttpResponseData::isConnectRequest = true, which is the flag that puts uWS into pass-through mode.Fix
Mirror the CONNECT handoff exactly:
socket[kEnableStreaming](true)— wirehandle.ondata/ondrainso_writeactually callsus_socket_buffered_js_writeand reads surface to the Duplex.socketHandle.markAsRawMode()— new host function onJSNodeHTTPServerSocket; setsisConnectRequest = trueon the uWSHttpResponseDataso uWS stops HTTP-parsing and routes bytes throughonSocketData.socket.once('close')instead of falling through tosocket.cork()and the response lifecycle.'upgrade'listener still falls through to'request', matching Node ("By default all HTTP upgrade requests are ignored (i.e. only regular 'request' events are emitted)").Tests
test/js/node/http/node-http-with-ws.test.tsadds two targeted cases — raw TCP and TLS — asserting:socket.on('data', …)on the server (uWS no longer swallows them as HTTP).socket.on('close')fires on clean teardown.Both fail against system Bun 1.3.14, pass with this change. The two existing tests in that file (
ws-shim path, TLS socket-close crash) still pass. No regressions in the rest oftest/js/node/http(the 3 pre-existing failures —node-http-uaf-fixture*.ts,issue#4295— fail on main without this diff).Related: #9882, #18945, #14522, #26924, #18569.