Files
bun.sh/test/js/web/websocket/websocket-close-fragmented.test.ts
robobun 686998ed3d Fix panic when WebSocket close frame is fragmented across TCP packets (#23832)
## 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>
2025-10-20 18:42:19 -07:00

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);
}
});
});