-
Notifications
You must be signed in to change notification settings - Fork 4.7k
fix(node:http): socket.write not sending data in upgrade event handler #27237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
robobun
wants to merge
2
commits into
main
Choose a base branch
from
claude/fix-http-upgrade-socket-write
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import http from "node:http"; | ||
| import net from "node:net"; | ||
|
|
||
| test("socket.write sends data in http upgrade event handler", async () => { | ||
| const { promise, resolve, reject } = Promise.withResolvers<void>(); | ||
|
|
||
| const server = http.createServer(); | ||
| server.on("upgrade", (_req, socket) => { | ||
| socket.write("x", () => { | ||
| // After the write completes, close the socket | ||
| socket.end(); | ||
| }); | ||
| }); | ||
|
|
||
| server.listen(0, "127.0.0.1", () => { | ||
| const addr = server.address() as net.AddressInfo; | ||
|
|
||
| const client = net.createConnection(addr.port, "127.0.0.1", () => { | ||
| client.write( | ||
| "GET / HTTP/1.1\r\n" + | ||
| "Host: 127.0.0.1\r\n" + | ||
| "Upgrade: websocket\r\n" + | ||
| "Connection: Upgrade\r\n" + | ||
| "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + | ||
| "Sec-WebSocket-Version: 13\r\n" + | ||
| "\r\n", | ||
| ); | ||
| }); | ||
|
|
||
| let received = ""; | ||
| client.on("data", (data: Buffer) => { | ||
| received += data.toString(); | ||
| }); | ||
|
|
||
| client.on("end", () => { | ||
| try { | ||
| expect(received).toBe("x"); | ||
| resolve(); | ||
| } catch (e) { | ||
| reject(e); | ||
| } finally { | ||
| server.close(); | ||
| } | ||
| }); | ||
|
|
||
| client.on("error", (err: Error) => { | ||
| server.close(); | ||
| reject(err); | ||
| }); | ||
| }); | ||
|
|
||
| await promise; | ||
| }); | ||
|
|
||
| test("socket.write with 101 handshake in http upgrade event handler", async () => { | ||
| const { promise, resolve, reject } = Promise.withResolvers<void>(); | ||
|
|
||
| const server = http.createServer(); | ||
| server.on("upgrade", (_req, socket) => { | ||
| socket.write( | ||
| "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\nhello from upgrade", | ||
| () => { | ||
| socket.end(); | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| server.listen(0, "127.0.0.1", () => { | ||
| const addr = server.address() as net.AddressInfo; | ||
|
|
||
| const client = net.createConnection(addr.port, "127.0.0.1", () => { | ||
| client.write( | ||
| "GET / HTTP/1.1\r\n" + | ||
| "Host: 127.0.0.1\r\n" + | ||
| "Upgrade: websocket\r\n" + | ||
| "Connection: Upgrade\r\n" + | ||
| "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + | ||
| "Sec-WebSocket-Version: 13\r\n" + | ||
| "\r\n", | ||
| ); | ||
| }); | ||
|
|
||
| let received = ""; | ||
| client.on("data", (data: Buffer) => { | ||
| received += data.toString(); | ||
| }); | ||
|
|
||
| client.on("end", () => { | ||
| try { | ||
| expect(received).toContain("HTTP/1.1 101 Switching Protocols"); | ||
| expect(received).toContain("hello from upgrade"); | ||
| resolve(); | ||
| } catch (e) { | ||
| reject(e); | ||
| } finally { | ||
| server.close(); | ||
| } | ||
| }); | ||
|
|
||
| client.on("error", (err: Error) => { | ||
| server.close(); | ||
| reject(err); | ||
| }); | ||
| }); | ||
|
|
||
| await promise; | ||
| }); | ||
|
|
||
| test("multiple socket.write calls in http upgrade event handler", async () => { | ||
| const { promise, resolve, reject } = Promise.withResolvers<void>(); | ||
|
|
||
| const server = http.createServer(); | ||
| server.on("upgrade", (_req, socket) => { | ||
| socket.write("first"); | ||
| socket.write("second"); | ||
| socket.write("third", () => { | ||
| socket.end(); | ||
| }); | ||
| }); | ||
|
|
||
| server.listen(0, "127.0.0.1", () => { | ||
| const addr = server.address() as net.AddressInfo; | ||
|
|
||
| const client = net.createConnection(addr.port, "127.0.0.1", () => { | ||
| client.write( | ||
| "GET / HTTP/1.1\r\n" + | ||
| "Host: 127.0.0.1\r\n" + | ||
| "Upgrade: websocket\r\n" + | ||
| "Connection: Upgrade\r\n" + | ||
| "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + | ||
| "Sec-WebSocket-Version: 13\r\n" + | ||
| "\r\n", | ||
| ); | ||
| }); | ||
|
|
||
| let received = ""; | ||
| client.on("data", (data: Buffer) => { | ||
| received += data.toString(); | ||
| }); | ||
|
|
||
| client.on("end", () => { | ||
| try { | ||
| expect(received).toContain("first"); | ||
| expect(received).toContain("second"); | ||
| expect(received).toContain("third"); | ||
| resolve(); | ||
| } catch (e) { | ||
| reject(e); | ||
| } finally { | ||
| server.close(); | ||
| } | ||
| }); | ||
|
|
||
| client.on("error", (err: Error) => { | ||
| server.close(); | ||
| reject(err); | ||
| }); | ||
| }); | ||
|
|
||
| await promise; | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 Pre-existing: The upgrade handler (line 608) is missing a
listenerCount("upgrade")guard that the CONNECT handler (line 538) already has — upgrade requests with no listeners will now enable streaming on an unmanaged socket and leak the internal promise. Additionally,assignSocketInternalat line 614 stores the close callback viasetCloseCallback(socket, ...), butNodeHTTPServerSocketnever callscallCloseCallback(this)on close (unlikeServerResponse.prototype.emit), so the internal promise returned fromonNodeHTTPRequesthangs forever for upgrade connections. The CONNECT handler avoids both issues by checkinglistenerCountand usingsocket.once("close", resolve)directly.Extended reasoning...
Bug 1: Missing listenerCount check
The CONNECT handler at line 538 checks
server.listenerCount("connect") > 0before enabling streaming and emitting the event. If no listeners exist, it closes the socket handle. The upgrade handler at line 608 has no equivalent guard — it unconditionally callssocket[kEnableStreaming](true)and emits the"upgrade"event regardless of whether any listeners are registered.In Node.js's reference implementation (
_http_server.js), when there are no upgrade listeners,socket.destroy()is called to clean up the connection. Without this guard in Bun, upgrade requests to a server with no"upgrade"listener will set upondata/ondrainhandlers on a socket that nobody is managing, and the internal promise will hang forever.Before this PR, the no-listener case was relatively inert because streaming was disabled (writes silently failed). After this PR,
socket[kEnableStreaming](true)at line 609 is called unconditionally, making the leak slightly worse by activating event handlers on the orphaned socket.Bug 2: Internal promise never resolves for upgrade connections
The promise lifecycle for upgrade connections is broken. Here's the intended chain:
setCloseCallback(http_res, onClose)at line 602 stores the promise-resolvingonCloseashttp_res[kCloseCallback]assignSocketInternal(http_res, socket)at line 614 storesonServerResponseCloseassocket[kCloseCallback]callCloseCallback(socket)should fire →onServerResponseClose→emitCloseNT(http_res)→callCloseCallback(http_res)→onClose()→ resolveHowever, this chain is broken because
NodeHTTPServerSocketextendsDuplexand does not overrideemit()to intercept the"close"event. OnlyServerResponse.prototype.emit(line 1631) callscallCloseCallback(this)when it sees a"close"event. When the socket's#onClose()method fires (line 899), it does not invokecallCloseCallback(socket), soonServerResponseCloseis never called.For normal requests this doesn't matter because resolution goes through
http_res.end()→http_res.emit("close")→callCloseCallback(http_res)→onClose(). But for upgrade connections, user code works with the raw socket and never callshttp_res.end().Proof by comparison with CONNECT handler
The CONNECT handler at lines 540-544 correctly handles both issues:
It checks
listenerCountfirst, and ties promise resolution directly to the socket's"close"event via.once(), bypassing theassignSocketInternal/callCloseCallbackmechanism entirely.Impact
Both issues are pre-existing (the upgrade branch with
assignSocketInternaland withoutlistenerCountexisted before this PR). However, this PR makes upgrade connections actually functional (writes now work), which means more users will exercise this code path and encounter these issues. The fix should mirror the CONNECT handler: guard withlistenerCount("upgrade") > 0, callsocket.destroy()when there are no listeners, and usesocket.once("close", resolve)for promise resolution instead of relying onassignSocketInternal.