ws: validate close(code, reason) like npm ws
Problem
- In the built-in
wsshim, a server connection'sws.close(code, reason)reachedServerWebSocket.close()unchecked.close(999),close(1015),close(-1)andclose(1000, "é".repeat(70))each put a Close frame on the wire. Peers report 1002, 1005 or 1007, and Chromium fails the connection for 1015 and for a reason cut inside a character. - npm ws throws in
Sender.prototype.closeand sends nothing: aTypeErroroutside 1000-1003, 1007-1014 and 3000-4999, aRangeErrorfor a reason over 123 bytes. The shim's client class threw aDOMExceptioninstead, and sent aUint8Arrayreason as"104,105".
Fix
closeReason()insrc/js/thirdparty/ws.jsports those checks with the same errors. Both socket classes run it on an OPEN socket before any state change, so a caught error leaves the socket usable.- Like npm ws,
datais ignored without a code or without alength, a typed array reason is sent as its bytes, and a socket that is not OPEN checks nothing. - Not changed:
ServerWebSocket.close()itself (#41665 covers its UTF-8 cut, a range check there changesBun.serve). Found by a parity audit, not a user report. - Verified:
test/js/first_party/ws/ws.test.ts, block "close() arguments" (all four fail on 1.4.3), plus the otherwssuites.
Background
"ws"always resolves to the shim. Its server socket sits on aBun.servewebsocket, where uWSend()casts the code touint16_tand cuts the reason at byte 123. Its client socket wraps the globalWebSocket, which checks the same codes since #32820.- RFC 6455 §7.4: 1004, 1005, 1006 and 1015 never go in a Close frame. §5.5: its payload is at most 125 bytes, 2 of them the code.
Notes
Repro, bun shimclose.mjs with no node_modules against node shimclose.mjs with ws 8.18.3:
import { WebSocketServer, WebSocket } from "ws";
const CASES = [[999],[1004],[1005],[1006],[1015],[1016],[2999],[5000],[65535],[100000],[-1],[NaN],["1000"],
[1000,"q".repeat(200)],[1000,"é".repeat(70)],[undefined,"q".repeat(200)],[1000,Buffer.from("buf")],
[1000,new Uint8Array([104,105])],[1000,42],[4000,"ok"]];
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }); let i = 0;
wss.on("listening", next);
function next() { if (i >= CASES.length) process.exit(0); const a = CASES[i++];
const c = new WebSocket("ws://127.0.0.1:" + wss.address().port);
c.on("close", (code, r) => { console.log(" peer saw", code, JSON.stringify(String(r).slice(0, 12)), Buffer.byteLength(String(r)), "B"); next(); });
c.on("error", () => {}); }
wss.on("connection", ws => { const a = CASES[i - 1];
try { ws.close(...a); console.log(`close(${String(a[0])}) -> no throw`); }
catch (e) { console.log(`close(${String(a[0])}) -> ${e.constructor.name}: ${e.message}`); ws.terminate(); } });
bun 1.4.3: every call returns. The peer sees 1002 for 999, 1004, 1015, 1016, 2999, 5000, 65535, 100000 and -1, 1005 for 1005, 1006 and NaN, a reason cut to 123 bytes for the 200-byte case, 1007 for "é"×70, "104,105" for the Uint8Array and "42" for 42. close("1000") threw close requires a numeric code or undefined and left the socket in CLOSING with the connection open.
node 26 + ws 8.18.3: TypeError for the 13 code cases, RangeError for the two long reasons. close(undefined, long) sends an empty Close frame, the Uint8Array arrives as hi, 42 is ignored, close(4000, "ok") arrives as given. With this change the shim matches that table, except that a no-code close() still sends code 1000 where npm ws sends an empty Close frame (the peer sees 1005). That difference existed before and is not touched here.
npm ws sets _readyState = CLOSING before Sender.close throws, which leaves a socket whose close() no longer does anything. The shim checks first, so the state is unchanged after a throw. The first test pins that (readyState 1, then close(4000, "ok") reaches the peer).
Client class before this change: an OPEN socket threw InvalidAccessError or SyntaxError (DOMException) from the native close(), close("1000") was accepted as 1000, and a CONNECTING, CLOSING or CLOSED socket also threw for a bad code. npm ws only checks on an OPEN socket: CONNECTING aborts the handshake, CLOSING and CLOSED return. The client class now does the same by calling the native close() without arguments in those states. The native check from #32820 stays as it is underneath.
A typed array reason is decoded to UTF-8 before the length check, so the 123-byte limit applies to the bytes that go on the wire. For a view that holds invalid UTF-8 this differs from npm ws, which sends the raw bytes.
#32820 added the same code set to the native client close() and named ServerWebSocket#close() as not addressed. This change covers the ws side of that. #41665 is the native-side complement for the reason. Overlap with open PRs: #35031 (WHATWG range for the global WebSocket.close()) carries an older version of the code check for both shim classes, without the reason check; its ws.js hunks are superseded by this. #39802 rewrites the server socket close lifecycle and touches the same close() method; closeReason() has to run before its pending-close bookkeeping, otherwise the conflict is mechanical.
Suites run on the debug build: test/js/first_party/ws/ws.test.ts (69 pass), ws-upgrade-events.test.ts, ws-proxy.test.ts, test/js/node/http/node-http-with-ws.test.ts, test/js/web/websocket/websocket-client.test.ts, websocket-close-connecting.test.ts, websocket-buffered-amount.test.ts, test/regression/issue/{32734,3613,012040,26358,29684,03844}.
Self-reviewed: the review asked for the client class to go through the same helper and for the PR text to name the related PRs; both done.
[human-review] gate passed · iteration 0 · 2 files touched
fails on main (without fix)
ASAN without fix: 4 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/first_party/ws/ws.test.ts
bun test v1.4.3 (f42e98025)
test/js/first_party/ws/ws.test.ts:
Listening: ws://127.0.0.1:38937/
(pass) WebSocket > url [1678.82ms]
Listening: ws://127.0.0.1:34335/
Received upgrade: {
host: "127.0.0.1:34335",
connection: "Upgrade",
upgrade: "websocket",
"sec-websocket-version": "13",
"sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
"sec-websocket-key": "Dg9qPYNHFtpLARTy6EDoaA==",
}
Received connection: 127.0.0.1
(pass) WebSocket > readyState [1896.12ms]
Listening: ws://127.0.0.1:40347/
(pass) WebSocket > binaryType > (default) [1415.43ms]
Listening: ws://127.0.0.1:33541/
(pass) WebSocket > binaryType > (invalid) [1540.05ms]
Listening: ws://127.0.0.1:42621/
Received upgrade: {
host: "127.0.0.1:42621",
connection: "Upgrade",
upgrade: "websocket",
"sec-websocket-version": "13",
"sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
"sec-websocket-key": "3xa/by3IhKDQ2WDMiY7jLQ==",
}
Received connection: 127.0.0.1
Received message: <Buffer
... (truncated)
release without fix: 4 FAILED
bun test v1.4.3-canary.1 (f42e98025)
test/js/first_party/ws/ws.test.ts:
Listening: ws://127.0.0.1:38077/
(pass) WebSocket > url [37.20ms]
Listening: ws://127.0.0.1:44661/
Received upgrade: {
host: "127.0.0.1:44661",
connection: "Upgrade",
upgrade: "websocket",
"sec-websocket-version": "13",
"sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
"sec-websocket-key": "2L84r4Bi8fQn54EQHao5PQ==",
}
Received connection: 127.0.0.1
Received close: 1000
(pass) WebSocket > readyState [30.61ms]
Listening: ws://127.0.0.1:42289/
(pass) WebSocket > binaryType > (default) [23.39ms]
Listening: ws://127.0.0.1:40577/
(pass) WebSocket > binaryType > (invalid) [24.92ms]
Listening: ws://127.0.0.1:38219/
Received upgrade: {
host: "127.0.0.1:38219",
connection: "Upgrade",
upgrade: "websocket",
"sec-websocket-version": "13",
"sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
"sec-websocket-key": "8pG0Ctyj68F6IrF473sbGw==",
}
Received connection: 127.0.0.1
Received message: <Buffer 00>
Received ping: <Buffer >
Received pong: <Buffer >
Received pong: <Buffer >
(pass) WebSocket > binaryType > nodebuffer [35.46ms]
Listening:
... (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/pr_gate.xml" test/js/first_party/ws/ws.test.ts
bun test v1.4.3 (f42e98025)
test/js/first_party/ws/ws.test.ts:
Listening: ws://127.0.0.1:37813/
(pass) WebSocket > url [1416.01ms]
Listening: ws://127.0.0.1:44623/
Received upgrade: {
host: "127.0.0.1:44623",
connection: "Upgrade",
upgrade: "websocket",
"sec-websocket-version": "13",
"sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
"sec-websocket-key": "AWjmqcQ1HlFLqkl1Nvlkng==",
}
Received connection: 127.0.0.1
(pass) WebSocket > readyState [1787.18ms]
Listening: ws://127.0.0.1:41347/
(pass) WebSocket > binaryType > (default) [1303.14ms]
Listening: ws://127.0.0.1:46095/
(pass) WebSocket > binaryType > (invalid) [1316.91ms]
Listening: ws://127.0.0.1:41333/
Received upgrade: {
host: "127.0.0.1:41333",
connection: "Upgrade",
upgrade: "websocket",
"sec-websocket-version": "13",
"sec-websocket-extensions": "permessage-deflate; client_max_window_bits",
"sec-websocket-key": "SzDNVM/MdhYl3omCD8mH9A==",
}
Received connection: 127.0.0.1
Received message: <Buffer
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 753ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (8710ms)
Bundle modules (90ms)
Postprocesss modules (159ms)
Bundle Functions (410ms)
Generate Code (38ms)
[9.41s] Bundled "src/js" for production
2595 kb
197 internal modules
13 native modules
50 internal functions across 16 files
[1/5] cargo bun_runtime → libbun_runtime.a
[1m[92m Compiling[0m bun_install v0.0.0 (/workspace/bun/src/install)
[1m[92m Compiling[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
[1m[92m Compiling[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
[1m[92m Compiling[0m bun_patch_jsc v0.0.0 (/workspace/bun/src/patch_jsc)
[1m[92m Compiling[0m bun_css_jsc v0.0.0 (/workspace/bun/src/css_jsc)
[1m[92m Compiling[0m bun_semver_jsc v0.0.0 (/workspace/bun/src/semver_jsc)
[1m[92m Compiling[0m bun_sourcemap_jsc v0.0.0 (/workspace/bun/src/sourcemap_jsc)
[1m[92m Compiling[0m bun_http_jsc v0.0.0 (/workspace/bun/src/http_jsc)
[1m[92m Compiling[0m bun_install_jsc v0.0.0 (/workspa
... (truncated)
diff hotspot
src/js/thirdparty/ws.js | 35 +++++++++++-
test/js/first_party/ws/ws.test.ts | 117 ++++++++++++++++++++++++++++++++++++++
2 files changed, 149 insertions(+), 3 deletions(-)
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
file reads edits tests
src/js/thirdparty/ws.js 5 5 15
test/js/first_party/ws/ws.test.ts 4 4 15
root cause · written by the author bot
Bun's built-in ws shim forwarded close(code, reason) straight to the underlying socket without running the argument checks that npm ws performs in Sender.prototype.close, so codes an endpoint may not send (999, 1004-1006, 1015, 1016, 2999, 5000 and out-of-range values) and reasons longer than 123 bytes were written to the wire, where peers and real browsers rejected them as protocol errors or truncated the reason mid-codepoint. The fix adds upstream-matching validation helpers that reject invalid status codes with a TypeError and over-long reasons with a RangeError, and routes bot…