diff --git a/src/codegen/bake-codegen.ts b/src/codegen/bake-codegen.ts index b60a3dba613..b720e570783 100644 --- a/src/codegen/bake-codegen.ts +++ b/src/codegen/bake-codegen.ts @@ -53,7 +53,7 @@ async function run() { side: JSON.stringify(side), IS_ERROR_RUNTIME: String(file === "error"), IS_BUN_DEVELOPMENT: String(!!debug), - OVERLAY_CSS: css("../runtime/bake/client/overlay.css", !!debug), + OVERLAY_CSS: JSON.stringify(css("../runtime/bake/client/overlay.css", !!debug)), }, minify: { syntax: !debug, diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index d4b631519cf..c25be89cff9 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -1658,7 +1658,21 @@ impl WebSocket { if !this.has_tcp() { return; } - let mut close_reason_buf = [0u8; 128]; + // `send_close_with_body` takes `&mut [u8; 125]`; keep the array at + // 125 bytes so the borrow covers the full provenance and matches + // the other caller (line 1006, server-initiated close echo). + let mut close_reason_buf = [0u8; 125]; + // RFC 6455 §5.5.1: the close-frame payload is ≤ 125 bytes total and + // the 7-bit header length field is the payload length — there is no + // extended-length encoding for control frames. `send_close_with_body` + // prepends the 2-byte status code separately and computes the header + // length as `(body_len + 2) & 0x7F`. A reason of 124 or 125 UTF-8 + // bytes therefore writes a header length of 126 or 127, which are + // the 16/64-bit extended-length sentinels per §5.2 and malform the + // frame. Cap the transcode cursor at 123 bytes so the overflow arms + // below (`WriteZero` / `NoSpaceLeft`) bail via `break 'inner` at the + // correct boundary. + const MAX_REASON_BYTES: usize = 123; // SAFETY: reason is null or a valid *const ZigString from C++ if let Some(str) = unsafe { reason.as_ref() } { 'inner: { @@ -1668,9 +1682,9 @@ impl WebSocket { // replicate the encoding switch directly: 8-bit copies bytes, // 16-bit transcodes via `to_owned_slice()` (UTF-16 → UTF-8). use std::io::Write; - let mut cursor = std::io::Cursor::new(&mut close_reason_buf[..]); + let mut cursor = std::io::Cursor::new(&mut close_reason_buf[..MAX_REASON_BYTES]); if str.is_16bit() { - // Allocates; close-reason is bounded ≤125 bytes and this + // Allocates; close-reason is bounded ≤123 bytes and this // path is cold (close handshake). let utf8 = str.to_owned_slice(); if cursor.write_all(&utf8).is_err() { @@ -1697,16 +1711,9 @@ impl WebSocket { cursor.set_position((pos + result.written as usize) as u64); } let wrote_len = cursor.position() as usize; - // 125-byte close-frame payload budget minus the 2-byte status code. - if wrote_len > 123 { - break 'inner; - } - // SAFETY: `close_reason_buf` is a 128-byte stack array, so its - // first 125 bytes form a valid `[u8; 125]` (align 1); `cursor` - // (the only other borrow) ended at `wrote_len` above, so this - // `&mut` is unique. - let buf = unsafe { &mut *close_reason_buf.as_mut_ptr().cast::<[u8; 125]>() }; - this.send_close_with_body(code, Some(buf), wrote_len); + // Cursor is over a 123-byte view; overflow takes `break 'inner` above. + debug_assert!(wrote_len <= MAX_REASON_BYTES); + this.send_close_with_body(code, Some(&mut close_reason_buf), wrote_len); return; } } diff --git a/test/js/web/websocket/websocket-close-fragmented.test.ts b/test/js/web/websocket/websocket-close-fragmented.test.ts index a15c58678d3..bcfde3baedc 100644 --- a/test/js/web/websocket/websocket-close-fragmented.test.ts +++ b/test/js/web/websocket/websocket-close-fragmented.test.ts @@ -1,5 +1,6 @@ import { TCPSocketListener } from "bun"; import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; const hostname = "127.0.0.1"; const port = 0; @@ -124,4 +125,107 @@ describe("WebSocket", () => { server?.stop(true); } }); + + // Regression: the close() reason was transcoded into a fixed 128-byte + // stack buffer and then pointer-cast to `&mut [u8; 125]` before being + // passed on with `body_len = cursor.position()`. A UTF-16 reason of + // 42 code units of U+0800 passes the C++ 123-char limit (which bounds + // code units, not UTF-8 bytes) but transcodes to 126 UTF-8 bytes, + // overrunning the 125-byte reference, panicking the Rust side (`range + // end index 126 out of range for slice of length 125`) and aborting + // the process across `extern "C"`. + // + // The subprocess wrapper is deliberate: a panic in `WebSocket::close` + // terminates the WHOLE bun process, which would crash the test runner + // itself and leave no JUnit output. Spawning a child isolates the + // expected crash so the parent test can assert on the child's exit + // code. + // The 30s timeout (vs the 5s default) covers the pre-fix failure arm: + // the Rust panic in `WebSocket::close` fires SIGILL, which the debug + // crash handler catches and runs `llvm-symbolizer` on — ~6s in ASAN + // builds. The happy path is ~1s. + test("close() with reason that transcodes beyond 123 UTF-8 bytes does not crash", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", CLOSE_LONG_REASON_FIXTURE], + env: bunEnv, + stdout: "pipe", + stderr: "ignore", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + // With the fix: the fixture reaches the close listener, prints + // `close:1000`, and exits cleanly. Without the fix: the child aborts + // inside `WebSocket::close` before the listener fires — stdout is + // empty and exitCode is non-zero (SIGILL from the Rust panic). + expect({ stdout: stdout.trim(), exitCode }).toEqual({ + stdout: "close:1000", + exitCode: 0, + }); + }, 30_000); +}); + +// Raw-socket WebSocket handshake + close(1000, reason-transcoding-to-126-UTF-8-bytes). +// Runs in a child process so a panic in the native close path aborts the +// child, not the test runner. +const CLOSE_LONG_REASON_FIXTURE = /* js */ ` +const MAX_HEADER_SIZE = 16 * 1024; +const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket, data) { + if (socket.data?.handshakeComplete) { + if (!socket.data.receivedCloseFrame && data.length > 0 && data[0] === 0x88) { + socket.data.receivedCloseFrame = true; + socket.write(new Uint8Array([0x88, 0x00])); + socket.flush(); + socket.end(); + } + return; + } + socket.data ||= { handshakeBuffer: new Uint8Array(0), handshakeComplete: false, receivedCloseFrame: false }; + const prev = socket.data.handshakeBuffer; + const merged = new Uint8Array(prev.length + data.length); + merged.set(prev); + merged.set(data, prev.length); + socket.data.handshakeBuffer = merged; + if (merged.length > MAX_HEADER_SIZE) { socket.end(); return; } + const text = new TextDecoder("utf-8").decode(merged); + if (text.indexOf("\\r\\n\\r\\n") === -1) return; + const magic = /Sec-WebSocket-Key:\\s*(.*)\\r\\n/i.exec(text); + if (!magic) { socket.end(); return; } + const hasher = new Bun.CryptoHasher("sha1"); + hasher.update(magic[1].trim()); + hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + const accept = hasher.digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\\r\\n" + + "Upgrade: websocket\\r\\n" + + "Connection: Upgrade\\r\\n" + + "Sec-WebSocket-Accept: " + accept + "\\r\\n" + + "\\r\\n", + ); + socket.flush(); + socket.data.handshakeComplete = true; + }, + }, +}); + +const { promise, resolve } = Promise.withResolvers(); +const ws = new WebSocket("ws://" + server.hostname + ":" + server.port); +ws.addEventListener("open", () => { + // 42 code units × 3 UTF-8 bytes = 126 bytes — one byte past the + // 125-byte close-frame payload cap. C++ spec check bounds on UTF-16 + // code-unit count (42 < 123), so this reaches the native close path. + ws.close(1000, "\\u0800".repeat(42)); +}); +ws.addEventListener("close", event => { + console.log("close:" + event.code); + resolve(); +}); +ws.addEventListener("error", () => { + console.log("error"); + resolve(); }); +await promise; +server.stop(true); +`;