mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 18:38:55 +00:00
## Summary Fixes a panic that occurred when a WebSocket close frame's payload was split across multiple TCP packets. ## The Bug The panic occurred at `websocket_client.zig:681`: ``` panic: index out of bounds: index 24, len 14 ``` This happened when: - A close frame had a payload of 24 bytes (2 byte code + 22 byte reason) - The first TCP packet contained 14 bytes (header + partial payload) - The code tried to access `data[2..24]` causing the panic ## Root Causes 1. **Bounds checking issue**: The code assumed all close frame data would arrive in one packet and tried to `@memcpy` without verifying sufficient data was available. 2. **Premature flag setting**: `close_received = true` was set immediately upon entering the close state. This prevented `handleData` from being called again when the remaining bytes arrived (early return at line 354). ## The Fix Implemented proper fragmentation handling for close frames, following the same pattern used for ping frames: - Added `close_frame_buffering` flag to track buffering state - Buffer incoming data incrementally using the existing `ping_frame_bytes` buffer - Track total expected length and bytes received so far - Only set `close_received = true` after all bytes are received - Wait for more data if the frame is incomplete ## Testing - Created two regression tests that fragment close frames across multiple packets - All existing WebSocket tests pass (`test/js/web/websocket/`) - Verified the original panic no longer occurs ## Related This appears to be the root cause of crashes reported on Windows when WebSocket connections close, particularly when close frames have reasons that get fragmented by the network stack. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
128 lines
4.2 KiB
TypeScript
128 lines
4.2 KiB
TypeScript
import { TCPSocketListener } from "bun";
|
|
import { describe, expect, test } from "bun:test";
|
|
|
|
const hostname = "127.0.0.1";
|
|
const port = 0;
|
|
const MAX_HEADER_SIZE = 16 * 1024; // 16KB max for handshake headers
|
|
|
|
describe("WebSocket", () => {
|
|
test("fragmented close frame", async () => {
|
|
let server: TCPSocketListener | undefined;
|
|
let client: WebSocket | undefined;
|
|
let handshakeBuffer = new Uint8Array(0);
|
|
let handshakeComplete = false;
|
|
|
|
try {
|
|
server = Bun.listen({
|
|
socket: {
|
|
data(socket, data) {
|
|
if (handshakeComplete) {
|
|
// Client's close response - end the connection
|
|
socket.end();
|
|
return;
|
|
}
|
|
|
|
// Accumulate handshake data
|
|
const newBuffer = new Uint8Array(handshakeBuffer.length + data.length);
|
|
newBuffer.set(handshakeBuffer);
|
|
newBuffer.set(data, handshakeBuffer.length);
|
|
handshakeBuffer = newBuffer;
|
|
|
|
// Prevent unbounded growth
|
|
if (handshakeBuffer.length > MAX_HEADER_SIZE) {
|
|
socket.end();
|
|
throw new Error("Handshake headers too large");
|
|
}
|
|
|
|
// Check for end of HTTP headers
|
|
const dataStr = new TextDecoder("utf-8").decode(handshakeBuffer);
|
|
const endOfHeaders = dataStr.indexOf("\r\n\r\n");
|
|
if (endOfHeaders === -1) {
|
|
// Need more data
|
|
return;
|
|
}
|
|
|
|
if (!dataStr.startsWith("GET")) {
|
|
throw new Error("Invalid handshake");
|
|
}
|
|
|
|
const magic = /Sec-WebSocket-Key:\s*(.*)\r\n/i.exec(dataStr);
|
|
if (!magic) {
|
|
throw new Error("Missing Sec-WebSocket-Key");
|
|
}
|
|
|
|
const hasher = new Bun.CryptoHasher("sha1");
|
|
hasher.update(magic[1].trim());
|
|
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
|
|
const accept = hasher.digest("base64");
|
|
|
|
// Respond with a websocket handshake
|
|
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();
|
|
|
|
handshakeComplete = true;
|
|
|
|
// Send a close frame split across two writes to simulate TCP fragmentation.
|
|
// Close frame: FIN=1, opcode=8 (close), payload = 2 byte code + 21 byte reason
|
|
const closeCode = 1000;
|
|
const closeReason = "fragmented close test";
|
|
const reasonBytes = new TextEncoder().encode(closeReason);
|
|
const payloadLength = 2 + reasonBytes.length; // 23 bytes total
|
|
|
|
// Ensure payload fits in single-byte length field
|
|
if (payloadLength >= 126) {
|
|
throw new Error("Payload too large for this test");
|
|
}
|
|
|
|
// Part 1: Frame header (2 bytes) + close code (2 bytes) + first 10 bytes of reason = 14 bytes
|
|
const part1 = new Uint8Array(2 + 2 + 10);
|
|
part1[0] = 0x88; // FIN + Close opcode
|
|
part1[1] = payloadLength; // Single-byte payload length
|
|
part1[2] = (closeCode >> 8) & 0xff;
|
|
part1[3] = closeCode & 0xff;
|
|
part1.set(reasonBytes.slice(0, 10), 4);
|
|
|
|
socket.write(part1);
|
|
socket.flush();
|
|
|
|
// Part 2: Remaining 11 bytes of the close reason
|
|
setTimeout(() => {
|
|
socket.write(reasonBytes.slice(10));
|
|
socket.flush();
|
|
}, 10);
|
|
},
|
|
},
|
|
hostname,
|
|
port,
|
|
});
|
|
|
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
|
|
client = new WebSocket(`ws://${server.hostname}:${server.port}`);
|
|
client.addEventListener("error", () => {
|
|
reject(new Error("WebSocket error"));
|
|
});
|
|
client.addEventListener("close", event => {
|
|
try {
|
|
expect(event.code).toBe(1000);
|
|
expect(event.reason).toBe("fragmented close test");
|
|
resolve();
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
|
|
await promise;
|
|
} finally {
|
|
client?.close();
|
|
server?.stop(true);
|
|
}
|
|
});
|
|
});
|