From cbec6b8015431bebba7482aff7b8e748cc716df2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:42:57 +0000 Subject: [PATCH 01/13] Implement client WebSocket.bufferedAmount The client WebSocket.bufferedAmount getter was effectively hard-wired to 0: m_bufferedAmount was only ever written by a didUpdateBufferedAmount() that nothing called, and the update lines in the send paths were commented out. Bytes queued by send() to a peer that stopped reading were genuinely buffered in-process (RSS grows by the full payload) but bufferedAmount never reflected them, so send backpressure on a client WebSocket was unobservable. Expose the pending send-buffer size from the Rust client via Bun__WebSocketClient__getBufferedAmount / ...TLS... (the in-process send_buffer's readable length, plus any encrypted bytes still held by the proxy tunnel) and query it live from WebSocket::bufferedAmount() for the Client/ClientSSL connection kinds. This mirrors ServerWebSocket's getBufferedAmount(), which already queries the live buffer. --- src/http_jsc/websocket_client.rs | 25 +++++++ .../websocket_client/WebSocketProxyTunnel.rs | 5 ++ src/jsc/bindings/headers.h | 2 + src/jsc/bindings/webcore/WebSocket.cpp | 24 ++++-- .../websocket-buffered-amount.test.ts | 73 +++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 test/js/web/websocket/websocket-buffered-amount.test.ts diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index cc968f08bf5..af60e6cf1cc 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -2092,6 +2092,24 @@ impl WebSocket { // This is under-estimated a little, as we don't include usockets context. cost } + + /// Bytes queued by `send()` that have not yet been written to the socket. + /// Backs the client `WebSocket.bufferedAmount` getter. Includes the framing + /// bytes of buffered frames (the send buffer holds fully framed messages), + /// plus any encrypted bytes the proxy tunnel still holds. + // + // `extern "C"` entrypoint; `this` is non-null by C++ contract (see SAFETY comment below). + #[allow(clippy::not_unsafe_ptr_arg_deref)] + pub extern "C" fn get_buffered_amount(this: *const Self) -> usize { + // SAFETY: called from C++ with a valid pointer + let this = unsafe { &*this }; + let mut buffered = this.send_buffer.readable_length(); + if let Some(tunnel) = &this.proxy_tunnel { + // SAFETY: `tunnel` holds a live ref (RefPtr has no `Deref`). + buffered += unsafe { tunnel.as_ref() }.buffered_amount(); + } + buffered + } } // ────────────────────────────────────────────────────────────────────────── @@ -2110,6 +2128,7 @@ macro_rules! export_websocket_client { cancel = $cancel:ident, close = $close:ident, finalize = $finalize:ident, + get_buffered_amount = $get_buffered_amount:ident, init = $init:ident, init_with_tunnel = $init_with_tunnel:ident, memory_cost = $memory_cost:ident, @@ -2130,6 +2149,10 @@ macro_rules! export_websocket_client { WebSocket::<$ssl>::finalize(this) } #[unsafe(no_mangle)] + pub extern "C" fn $get_buffered_amount(this: *const WebSocket<$ssl>) -> usize { + WebSocket::<$ssl>::get_buffered_amount(this) + } + #[unsafe(no_mangle)] pub extern "C" fn $init( outgoing: *mut CppWebSocket, input_socket: *mut c_void, @@ -2200,6 +2223,7 @@ export_websocket_client!( cancel = Bun__WebSocketClient__cancel, close = Bun__WebSocketClient__close, finalize = Bun__WebSocketClient__finalize, + get_buffered_amount = Bun__WebSocketClient__getBufferedAmount, init = Bun__WebSocketClient__init, init_with_tunnel = Bun__WebSocketClient__initWithTunnel, memory_cost = Bun__WebSocketClient__memoryCost, @@ -2212,6 +2236,7 @@ export_websocket_client!( cancel = Bun__WebSocketClientTLS__cancel, close = Bun__WebSocketClientTLS__close, finalize = Bun__WebSocketClientTLS__finalize, + get_buffered_amount = Bun__WebSocketClientTLS__getBufferedAmount, init = Bun__WebSocketClientTLS__init, init_with_tunnel = Bun__WebSocketClientTLS__initWithTunnel, memory_cost = Bun__WebSocketClientTLS__memoryCost, diff --git a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs index 9e9a3406ad0..3af77882766 100644 --- a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs +++ b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs @@ -600,6 +600,11 @@ impl WebSocketProxyTunnel { pub(crate) fn has_backpressure(&self) -> bool { self.write_buffer.is_not_empty() } + + /// Encrypted bytes still buffered in the tunnel awaiting a writable socket. + pub(crate) fn buffered_amount(&self) -> usize { + self.write_buffer.size() + } } impl Drop for WebSocketProxyTunnel { diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index c85e7379065..56cdf95298d 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -646,6 +646,7 @@ ZIG_DECL void* Bun__WebSocketClient__init(CppWebSocket* arg0, void* arg1, JSC::J ZIG_DECL void Bun__WebSocketClient__writeBinaryData(WebSocketClient* arg0, const unsigned char* arg1, size_t arg2, unsigned char arg3); ZIG_DECL void Bun__WebSocketClient__writeString(WebSocketClient* arg0, const ZigString* arg1, unsigned char arg2); ZIG_DECL size_t Bun__WebSocketClient__memoryCost(WebSocketClient* arg0); +ZIG_DECL size_t Bun__WebSocketClient__getBufferedAmount(WebSocketClient* arg0); #endif @@ -658,6 +659,7 @@ ZIG_DECL void* Bun__WebSocketClientTLS__init(CppWebSocket* arg0, void* arg1, JSC ZIG_DECL void Bun__WebSocketClientTLS__writeBinaryData(WebSocketClientTLS* arg0, const unsigned char* arg1, size_t arg2, unsigned char arg3); ZIG_DECL void Bun__WebSocketClientTLS__writeString(WebSocketClientTLS* arg0, const ZigString* arg1, unsigned char arg2); ZIG_DECL size_t Bun__WebSocketClientTLS__memoryCost(WebSocketClientTLS* arg0); +ZIG_DECL size_t Bun__WebSocketClientTLS__getBufferedAmount(WebSocketClientTLS* arg0); #endif #ifdef __cplusplus diff --git a/src/jsc/bindings/webcore/WebSocket.cpp b/src/jsc/bindings/webcore/WebSocket.cpp index 08971a3bb30..09660658f56 100644 --- a/src/jsc/bindings/webcore/WebSocket.cpp +++ b/src/jsc/bindings/webcore/WebSocket.cpp @@ -857,8 +857,6 @@ void WebSocket::sendWebSocketData(const char* baseAddress, size_t length, const switch (m_connectedWebSocketKind) { case ConnectedWebSocketKind::Client: { Bun__WebSocketClient__writeBinaryData(this->m_connectedWebSocket.client, reinterpret_cast(baseAddress), length, static_cast(op)); - // this->m_connectedWebSocket.client->send({ baseAddress, length }, opCode); - // this->m_bufferedAmount = this->m_connectedWebSocket.client->getBufferedAmount(); break; } case ConnectedWebSocketKind::ClientSSL: { @@ -887,8 +885,6 @@ void WebSocket::sendWebSocketString(const String& message, const Opcode op) case ConnectedWebSocketKind::Client: { auto zigStr = Zig::toZigString(message); Bun__WebSocketClient__writeString(this->m_connectedWebSocket.client, &zigStr, static_cast(op)); - // this->m_connectedWebSocket.client->send({ baseAddress, length }, opCode); - // this->m_bufferedAmount = this->m_connectedWebSocket.client->getBufferedAmount(); break; } case ConnectedWebSocketKind::ClientSSL: { @@ -1224,7 +1220,25 @@ WebSocket::State WebSocket::readyState() const unsigned WebSocket::bufferedAmount() const { - return saturateAdd(m_bufferedAmount, m_bufferedAmountAfterClose); + // Query the live send-buffer size from the connection so backpressure is + // observable while OPEN. After close the connection is gone, so fall back + // to m_bufferedAmount (set by didClose() to the unhandled buffered amount). + size_t buffered = m_bufferedAmount; + switch (m_connectedWebSocketKind) { + case ConnectedWebSocketKind::Client: + buffered = Bun__WebSocketClient__getBufferedAmount(this->m_connectedWebSocket.client); + break; + case ConnectedWebSocketKind::ClientSSL: + buffered = Bun__WebSocketClientTLS__getBufferedAmount(this->m_connectedWebSocket.clientSSL); + break; + case ConnectedWebSocketKind::None: + break; + } + + unsigned clamped = buffered > std::numeric_limits::max() + ? std::numeric_limits::max() + : static_cast(buffered); + return saturateAdd(clamped, m_bufferedAmountAfterClose); } String WebSocket::protocol() const diff --git a/test/js/web/websocket/websocket-buffered-amount.test.ts b/test/js/web/websocket/websocket-buffered-amount.test.ts new file mode 100644 index 00000000000..e1e54716afc --- /dev/null +++ b/test/js/web/websocket/websocket-buffered-amount.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import crypto from "node:crypto"; +import net from "node:net"; + +const WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +// Raw TCP server that completes the WebSocket handshake and then stops reading +// from the socket (`pause()`), so the client's outbound frames cannot drain to +// the peer and pile up in the in-process send buffer. +function nonDrainingServer(): Promise<{ port: number; close: () => void }> { + return new Promise((resolve, reject) => { + const server = net.createServer(sock => { + let buf = ""; + let upgraded = false; + sock.on("data", d => { + if (upgraded) return; + buf += d.toString("latin1"); + if (!buf.includes("\r\n\r\n")) return; + const key = /sec-websocket-key:\s*(.+)\r\n/i.exec(buf)?.[1]?.trim() ?? ""; + const accept = crypto.createHash("sha1").update(key + WS_MAGIC).digest("base64"); + sock.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`, + ); + upgraded = true; + sock.pause(); // never read the client's frames + }); + sock.on("error", () => {}); + }); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as net.AddressInfo; + resolve({ port: address.port, close: () => server.close() }); + }); + }); +} + +describe("WebSocket.bufferedAmount (client)", () => { + test("reflects the backlog queued to a peer that stopped reading", async () => { + const { port, close } = await nonDrainingServer(); + try { + const ws = new WebSocket(`ws://127.0.0.1:${port}/`); + const { promise, resolve, reject } = Promise.withResolvers<{ atOpen: number; max: number }>(); + ws.onerror = () => reject(new Error("unexpected error event")); + ws.onopen = () => { + // Nothing queued yet: the baseline must be 0, not a constant. + const atOpen = ws.bufferedAmount; + const chunk = Buffer.alloc(64 * 1024, 0x79).toString(); + let max = atOpen; + // 4000 * 64 KiB = ~250 MiB — far more than any socket buffer can accept, + // so the excess must queue in-process. + for (let i = 0; i < 4000; i++) { + ws.send(chunk); + if (ws.bufferedAmount > max) max = ws.bufferedAmount; + } + resolve({ atOpen, max }); + }; + const { atOpen, max } = await promise; + ws.close(); + + // Baseline with nothing queued. + expect(atOpen).toBe(0); + // Before the fix, bufferedAmount was hard-wired to 0 for the client + // WebSocket. It must now track the unsent backlog — which is far larger + // than a single 64 KiB frame once the peer stops reading. + expect(max).toBeGreaterThan(64 * 1024); + } finally { + close(); + } + }); +}); From 00deb1d95261d56bf366be2114eef06389c2cbd3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:45:06 +0000 Subject: [PATCH 02/13] [autofix.ci] apply automated fixes --- test/js/web/websocket/websocket-buffered-amount.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/js/web/websocket/websocket-buffered-amount.test.ts b/test/js/web/websocket/websocket-buffered-amount.test.ts index e1e54716afc..c61a8c7b1dc 100644 --- a/test/js/web/websocket/websocket-buffered-amount.test.ts +++ b/test/js/web/websocket/websocket-buffered-amount.test.ts @@ -17,7 +17,10 @@ function nonDrainingServer(): Promise<{ port: number; close: () => void }> { buf += d.toString("latin1"); if (!buf.includes("\r\n\r\n")) return; const key = /sec-websocket-key:\s*(.+)\r\n/i.exec(buf)?.[1]?.trim() ?? ""; - const accept = crypto.createHash("sha1").update(key + WS_MAGIC).digest("base64"); + const accept = crypto + .createHash("sha1") + .update(key + WS_MAGIC) + .digest("base64"); sock.write( "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + From 822eb1cf92a0a3ac7f63a420286e91db01efb555 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:10:54 +0000 Subject: [PATCH 03/13] websocket: keep bufferedAmount from resetting to 0 on close Per the WHATWG spec, bufferedAmount does not reset to zero once the connection closes; it only increases with subsequent send() calls. The previous change made bufferedAmount observable while OPEN, but close()/ terminate() tore down the connection (freeing the send buffer) and didClose() then reset m_bufferedAmount to 0, so a queued backlog snapped to 0 the moment close() was called. Snapshot the live buffered amount into m_bufferedAmount in close()/ terminate() before the connection is torn down, and stop didClose() from overwriting it (keep the larger value, since its unhandledBufferedAmount argument is always 0). Add a test asserting the backlog survives close(). --- src/jsc/bindings/webcore/WebSocket.cpp | 42 +++++++++++++------ .../websocket-buffered-amount.test.ts | 28 +++++++++++++ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/webcore/WebSocket.cpp b/src/jsc/bindings/webcore/WebSocket.cpp index 09660658f56..6f45d573e9c 100644 --- a/src/jsc/bindings/webcore/WebSocket.cpp +++ b/src/jsc/bindings/webcore/WebSocket.cpp @@ -174,6 +174,13 @@ static unsigned saturateAdd(unsigned a, unsigned b) return a + b; } +static unsigned clampToUnsigned(size_t value) +{ + return value > std::numeric_limits::max() + ? std::numeric_limits::max() + : static_cast(value); +} + ASCIILiteral WebSocket::subprotocolSeparator() { return ", "_s; @@ -987,17 +994,19 @@ ExceptionOr WebSocket::close(std::optional optionalCode, c m_state = CLOSING; switch (m_connectedWebSocketKind) { case ConnectedWebSocketKind::Client: { + // Snapshot the backlog before the connection (and its send buffer) is + // torn down: per spec bufferedAmount must not reset to 0 on close. + m_bufferedAmount = clampToUnsigned(Bun__WebSocketClient__getBufferedAmount(this->m_connectedWebSocket.client)); ZigString reasonZigStr = Zig::toZigString(reason); Bun__WebSocketClient__close(this->m_connectedWebSocket.client, code, &reasonZigStr); updateHasPendingActivity(); - // this->m_bufferedAmount = this->m_connectedWebSocket.client->getBufferedAmount(); break; } case ConnectedWebSocketKind::ClientSSL: { + m_bufferedAmount = clampToUnsigned(Bun__WebSocketClientTLS__getBufferedAmount(this->m_connectedWebSocket.clientSSL)); ZigString reasonZigStr = Zig::toZigString(reason); Bun__WebSocketClientTLS__close(this->m_connectedWebSocket.clientSSL, code, &reasonZigStr); updateHasPendingActivity(); - // this->m_bufferedAmount = this->m_connectedWebSocket.clientSSL->getBufferedAmount(); break; } // case ConnectedWebSocketKind::Server: { @@ -1032,11 +1041,15 @@ ExceptionOr WebSocket::terminate() m_state = CLOSING; switch (m_connectedWebSocketKind) { case ConnectedWebSocketKind::Client: { + // Snapshot the backlog before cancel() frees the send buffer, so + // bufferedAmount does not reset to 0 (see bufferedAmount()). + m_bufferedAmount = clampToUnsigned(Bun__WebSocketClient__getBufferedAmount(this->m_connectedWebSocket.client)); Bun__WebSocketClient__cancel(this->m_connectedWebSocket.client); updateHasPendingActivity(); break; } case ConnectedWebSocketKind::ClientSSL: { + m_bufferedAmount = clampToUnsigned(Bun__WebSocketClientTLS__getBufferedAmount(this->m_connectedWebSocket.clientSSL)); Bun__WebSocketClientTLS__cancel(this->m_connectedWebSocket.clientSSL); updateHasPendingActivity(); break; @@ -1220,25 +1233,24 @@ WebSocket::State WebSocket::readyState() const unsigned WebSocket::bufferedAmount() const { - // Query the live send-buffer size from the connection so backpressure is - // observable while OPEN. After close the connection is gone, so fall back - // to m_bufferedAmount (set by didClose() to the unhandled buffered amount). - size_t buffered = m_bufferedAmount; + // While OPEN, query the live send-buffer size from the connection so + // backpressure is observable. Once closed the connection is gone, but the + // spec requires bufferedAmount not to reset to 0 — close()/terminate() + // snapshot the final backlog into m_bufferedAmount, and send() after close + // adds to m_bufferedAmountAfterClose, so the total only ever increases. + unsigned buffered = m_bufferedAmount; switch (m_connectedWebSocketKind) { case ConnectedWebSocketKind::Client: - buffered = Bun__WebSocketClient__getBufferedAmount(this->m_connectedWebSocket.client); + buffered = clampToUnsigned(Bun__WebSocketClient__getBufferedAmount(this->m_connectedWebSocket.client)); break; case ConnectedWebSocketKind::ClientSSL: - buffered = Bun__WebSocketClientTLS__getBufferedAmount(this->m_connectedWebSocket.clientSSL); + buffered = clampToUnsigned(Bun__WebSocketClientTLS__getBufferedAmount(this->m_connectedWebSocket.clientSSL)); break; case ConnectedWebSocketKind::None: break; } - unsigned clamped = buffered > std::numeric_limits::max() - ? std::numeric_limits::max() - : static_cast(buffered); - return saturateAdd(clamped, m_bufferedAmountAfterClose); + return saturateAdd(buffered, m_bufferedAmountAfterClose); } String WebSocket::protocol() const @@ -1602,7 +1614,11 @@ void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code, bool wasClean = m_state == CLOSING && !unhandledBufferedAmount && code != 0; // WebSocketChannel::CloseEventCodeAbnormalClosure; m_state = CLOSED; - m_bufferedAmount = unhandledBufferedAmount; + // Don't reset the backlog: close()/terminate() already snapshotted the + // unsent bytes into m_bufferedAmount, and the spec requires bufferedAmount + // not to drop to 0 once closed. Keep whichever is larger. + if (unhandledBufferedAmount > m_bufferedAmount) + m_bufferedAmount = unhandledBufferedAmount; ASSERT(scriptExecutionContext()); this->m_connectedWebSocketKind = ConnectedWebSocketKind::None; this->m_upgradeClient = nullptr; diff --git a/test/js/web/websocket/websocket-buffered-amount.test.ts b/test/js/web/websocket/websocket-buffered-amount.test.ts index c61a8c7b1dc..b907030ca5c 100644 --- a/test/js/web/websocket/websocket-buffered-amount.test.ts +++ b/test/js/web/websocket/websocket-buffered-amount.test.ts @@ -73,4 +73,32 @@ describe("WebSocket.bufferedAmount (client)", () => { close(); } }); + + // Per the WHATWG spec, bufferedAmount "does not reset to zero once the + // connection closes" — after close() it only increases with further send(). + test("does not reset to 0 after close() while a backlog is queued", async () => { + const { port, close } = await nonDrainingServer(); + try { + const ws = new WebSocket(`ws://127.0.0.1:${port}/`); + const { promise, resolve, reject } = Promise.withResolvers<{ beforeClose: number; afterClose: number }>(); + ws.onerror = () => reject(new Error("unexpected error event")); + ws.onopen = () => { + const chunk = Buffer.alloc(64 * 1024, 0x7a).toString(); + for (let i = 0; i < 4000; i++) ws.send(chunk); + const beforeClose = ws.bufferedAmount; + ws.close(); + // Reading immediately after close() must retain the queued backlog, + // not snap back to 0. + const afterClose = ws.bufferedAmount; + resolve({ beforeClose, afterClose }); + }; + const { beforeClose, afterClose } = await promise; + + expect(beforeClose).toBeGreaterThan(64 * 1024); + // The backlog must survive the close() transition. + expect(afterClose).toBe(beforeClose); + } finally { + close(); + } + }); }); From 83525d3f5bce80e257d2ed42f7e7c413049d2d18 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:01:34 +0000 Subject: [PATCH 04/13] ci: retrigger From cb3f28fc0e1e5777b8dcffe40f8fb51dcb910cbe Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:35:02 +0000 Subject: [PATCH 05/13] websocket: preserve bufferedAmount on abrupt close too The close()/terminate() snapshot covered graceful teardown, but the abrupt-close path (protocol error, timeout, write failure, peer half-close) routes through fail(), which dispatches the JS close event via did_abrupt_close() -> didFailWithErrorCode() *before* cancel() frees the send buffer. didFailWithErrorCode() nulled the connection without snapshotting, so bufferedAmount still reset to 0 in the onclose handler. A C++-side live query there would form a whole-struct borrow of the Rust client while fail() still holds &mut self (a Stacked Borrows conflict), so instead capture the backlog on the Rust side before did_abrupt_close() and thread it through the FFI. didFailWithErrorCode() keeps the larger of the passed value and m_bufferedAmount, which also makes the socket-close path (buffer already cleared -> 0) a no-op. Add a test that aborts the client via an illegal masked server frame and asserts bufferedAmount survives into the close handler. --- src/http_jsc/websocket_client.rs | 31 +++++--- src/http_jsc/websocket_client/CppWebSocket.rs | 13 +++- .../WebSocketUpgradeClient.rs | 3 +- src/jsc/bindings/webcore/WebSocket.cpp | 15 +++- src/jsc/bindings/webcore/WebSocket.h | 2 +- .../websocket-buffered-amount.test.ts | 71 +++++++++++++++++++ 6 files changed, 117 insertions(+), 18 deletions(-) diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index af60e6cf1cc..77cae308e03 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -261,7 +261,12 @@ impl WebSocket { jsc::mark_binding!(); if let Some(ws) = self.outgoing_websocket.take() { log!("fail ({})", <&'static str>::from(code)); - CppWebSocket::opaque_ref(ws.as_ptr()).did_abrupt_close(code); + // Snapshot the unsent backlog before did_abrupt_close(): the JS + // close event fires synchronously inside it, yet the send buffer is + // not freed until cancel() below, so C++ must be told the amount now + // (it cannot query the connection across this &mut self borrow). + let buffered = self.buffered_amount(); + CppWebSocket::opaque_ref(ws.as_ptr()).did_abrupt_close(code, buffered); // SAFETY: `self: &mut Self` → `*mut Self`; allocation kept live by // the socket/tunnel I/O ref (or by caller's guard). unsafe { Self::deref(self) }; @@ -1632,7 +1637,11 @@ impl WebSocket { }; self.poll_ref.unref(Self::vm_loop_ctx(&self.global_this)); jsc::mark_binding!(); - CppWebSocket::opaque_ref(out.as_ptr()).did_abrupt_close(code); + // Capture the unsent backlog before the call (it may already be 0 if a + // caller such as handle_close() cleared the buffer first) so C++ can + // keep bufferedAmount from resetting to 0 on abrupt close. + let buffered = self.buffered_amount(); + CppWebSocket::opaque_ref(out.as_ptr()).did_abrupt_close(code, buffered); // SAFETY: `self: &mut Self` → `*mut Self`; allocation kept live by // caller's ref guard (see cancel/handle_close). unsafe { Self::deref(self) }; @@ -2097,19 +2106,21 @@ impl WebSocket { /// Backs the client `WebSocket.bufferedAmount` getter. Includes the framing /// bytes of buffered frames (the send buffer holds fully framed messages), /// plus any encrypted bytes the proxy tunnel still holds. - // - // `extern "C"` entrypoint; `this` is non-null by C++ contract (see SAFETY comment below). - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub extern "C" fn get_buffered_amount(this: *const Self) -> usize { - // SAFETY: called from C++ with a valid pointer - let this = unsafe { &*this }; - let mut buffered = this.send_buffer.readable_length(); - if let Some(tunnel) = &this.proxy_tunnel { + pub fn buffered_amount(&self) -> usize { + let mut buffered = self.send_buffer.readable_length(); + if let Some(tunnel) = &self.proxy_tunnel { // SAFETY: `tunnel` holds a live ref (RefPtr has no `Deref`). buffered += unsafe { tunnel.as_ref() }.buffered_amount(); } buffered } + + // `extern "C"` entrypoint; `this` is non-null by C++ contract (see SAFETY comment below). + #[allow(clippy::not_unsafe_ptr_arg_deref)] + pub extern "C" fn get_buffered_amount(this: *const Self) -> usize { + // SAFETY: called from C++ with a valid pointer + unsafe { &*this }.buffered_amount() + } } // ────────────────────────────────────────────────────────────────────────── diff --git a/src/http_jsc/websocket_client/CppWebSocket.rs b/src/http_jsc/websocket_client/CppWebSocket.rs index 602428111bd..1474a759810 100644 --- a/src/http_jsc/websocket_client/CppWebSocket.rs +++ b/src/http_jsc/websocket_client/CppWebSocket.rs @@ -44,7 +44,11 @@ unsafe extern "C" { buffered_len: usize, deflate_params: *const websocket_deflate::Params, ); - safe fn WebSocket__didAbruptClose(websocket_context: &CppWebSocket, reason: ErrorCode); + safe fn WebSocket__didAbruptClose( + websocket_context: &CppWebSocket, + reason: ErrorCode, + buffered_amount: usize, + ); fn WebSocket__didClose(websocket_context: &CppWebSocket, code: u16, reason: *const BunString); fn WebSocket__didReceiveText( websocket_context: &CppWebSocket, @@ -69,12 +73,15 @@ unsafe extern "C" { // borrows (often while `&mut WebSocket` is also live), so `&mut self` // would force needless `unsafe { &mut *ptr }` at every site. impl CppWebSocket { - pub(crate) fn did_abrupt_close(&self, reason: ErrorCode) { + /// `buffered_amount` is the sender's unsent backlog captured *before* this + /// call (the connection's send buffer may be freed during the abrupt-close + /// teardown), so C++ can keep `WebSocket.bufferedAmount` from resetting to 0. + pub(crate) fn did_abrupt_close(&self, reason: ErrorCode, buffered_amount: usize) { // SAFETY: VirtualMachine::get() returns the live current-thread VM; // event_loop() yields its raw event-loop pointer (live for VM lifetime). let event_loop = VirtualMachine::get().event_loop_mut(); event_loop.enter(); - WebSocket__didAbruptClose(self, reason); + WebSocket__didAbruptClose(self, reason, buffered_amount); event_loop.exit(); } diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 55d3def55e6..e6c60b73eeb 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -691,7 +691,8 @@ impl HTTPClient { // SAFETY: short-lived `&mut` for the field take; ends before the FFI call. let ws = unsafe { (*this).outgoing_websocket.take() }; if let Some(ws) = ws { - CppWebSocket::opaque_ref(ws).did_abrupt_close(code); + // The upgrade handshake has no send buffer yet, so the backlog is 0. + CppWebSocket::opaque_ref(ws).did_abrupt_close(code, 0); // SAFETY: `this` carries root provenance; may free `this`. unsafe { Self::deref(this) }; } diff --git a/src/jsc/bindings/webcore/WebSocket.cpp b/src/jsc/bindings/webcore/WebSocket.cpp index 6f45d573e9c..3fd9ec45b07 100644 --- a/src/jsc/bindings/webcore/WebSocket.cpp +++ b/src/jsc/bindings/webcore/WebSocket.cpp @@ -1683,13 +1683,22 @@ void WebSocket::didConnect(us_socket_t* socket, char* bufferedData, size_t buffe this->didConnect(); } -void WebSocket::didFailWithErrorCode(Bun::WebSocketErrorCode code) +void WebSocket::didFailWithErrorCode(Bun::WebSocketErrorCode code, size_t bufferedAmount) { // from new WebSocket() -> connect() if (m_state == CLOSED) return; + // Keep the backlog reported before this abrupt close (captured on the Rust + // side, since the connection's send buffer is freed during teardown) so + // bufferedAmount does not reset to 0 — see bufferedAmount(). Keep the larger + // value; this also makes the socket-close path (buffer already cleared → 0) + // a no-op. + unsigned clamped = clampToUnsigned(bufferedAmount); + if (clamped > m_bufferedAmount) + m_bufferedAmount = clamped; + this->m_upgradeClient = nullptr; if (this->m_connectedWebSocketKind == ConnectedWebSocketKind::ClientSSL) { this->m_connectedWebSocket.clientSSL = nullptr; @@ -1916,9 +1925,9 @@ extern "C" void WebSocket__didConnectWithTunnel(WebCore::WebSocket* webSocket, v webSocket->didConnectWithTunnel(tunnel, bufferedData, len, deflate_params); } -extern "C" void WebSocket__didAbruptClose(WebCore::WebSocket* webSocket, Bun::WebSocketErrorCode errorCode) +extern "C" void WebSocket__didAbruptClose(WebCore::WebSocket* webSocket, Bun::WebSocketErrorCode errorCode, size_t bufferedAmount) { - webSocket->didFailWithErrorCode(errorCode); + webSocket->didFailWithErrorCode(errorCode, bufferedAmount); } extern "C" void WebSocket__didClose(WebCore::WebSocket* webSocket, uint16_t errorCode, BunString* reason) { diff --git a/src/jsc/bindings/webcore/WebSocket.h b/src/jsc/bindings/webcore/WebSocket.h index 05653925061..6dae06d2b41 100644 --- a/src/jsc/bindings/webcore/WebSocket.h +++ b/src/jsc/bindings/webcore/WebSocket.h @@ -202,7 +202,7 @@ class WebSocket final : public RefCounted, public EventTargetWithInli void didClose(unsigned unhandledBufferedAmount, unsigned short code, const String& reason); void didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx); void didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params); - void didFailWithErrorCode(Bun::WebSocketErrorCode code); + void didFailWithErrorCode(Bun::WebSocketErrorCode code, size_t bufferedAmount = 0); void didReceiveMessage(String&& message); void didReceiveData(const char* data, size_t length); diff --git a/test/js/web/websocket/websocket-buffered-amount.test.ts b/test/js/web/websocket/websocket-buffered-amount.test.ts index b907030ca5c..01f3b00eea0 100644 --- a/test/js/web/websocket/websocket-buffered-amount.test.ts +++ b/test/js/web/websocket/websocket-buffered-amount.test.ts @@ -40,6 +40,18 @@ function nonDrainingServer(): Promise<{ port: number; close: () => void }> { }); } +// A server must not mask the frames it sends; a masked frame is a protocol +// violation that makes the client abort the connection via the abrupt-close +// (fail) path rather than a graceful close handshake. +function maskedServerFrame(): Buffer { + const payload = Buffer.from("x"); + // FIN + opcode 0x2 (binary), MASK bit set, 1-byte length, 4-byte mask key. + const header = Buffer.from([0x82, 0x80 | payload.length, 0x01, 0x02, 0x03, 0x04]); + const masked = Buffer.from(payload); + for (let i = 0; i < masked.length; i++) masked[i] ^= header[2 + (i % 4)]; + return Buffer.concat([header, masked]); +} + describe("WebSocket.bufferedAmount (client)", () => { test("reflects the backlog queued to a peer that stopped reading", async () => { const { port, close } = await nonDrainingServer(); @@ -101,4 +113,63 @@ describe("WebSocket.bufferedAmount (client)", () => { close(); } }); + + // The abrupt-close path (protocol error / timeout / write failure) must also + // preserve the backlog: the spec's "does not reset to 0" guarantee is not + // limited to graceful close(). Here the server sends a masked frame (illegal + // from a server), which aborts the client via the fail() path. + test("does not reset to 0 on an abrupt close while a backlog is queued", async () => { + const { promise: ready, resolve: onReady } = Promise.withResolvers(); + const server = net.createServer(sock => { + let buf = ""; + let upgraded = false; + sock.on("data", d => { + if (upgraded) return; + buf += d.toString("latin1"); + if (!buf.includes("\r\n\r\n")) return; + const key = /sec-websocket-key:\s*(.+)\r\n/i.exec(buf)?.[1]?.trim() ?? ""; + const accept = crypto + .createHash("sha1") + .update(key + WS_MAGIC) + .digest("base64"); + sock.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`, + ); + upgraded = true; + // Stop reading so the client's sends pile up, then send an illegal + // masked frame. A paused read side can still write. + sock.pause(); + sock.write(maskedServerFrame()); + }); + sock.on("error", () => {}); + }); + server.listen(0, "127.0.0.1", () => onReady((server.address() as net.AddressInfo).port)); + const port = await ready; + + try { + const ws = new WebSocket(`ws://127.0.0.1:${port}/`); + const { promise, resolve } = Promise.withResolvers<{ beforeClose: number; onClose: number }>(); + let beforeClose = 0; + ws.onopen = () => { + const chunk = Buffer.alloc(64 * 1024, 0x7b).toString(); + // Synchronous flood: completes before the event loop processes the + // server's incoming masked frame, so the backlog is queued first. + for (let i = 0; i < 4000; i++) ws.send(chunk); + beforeClose = ws.bufferedAmount; + }; + // The illegal frame aborts the connection; bufferedAmount read in the + // close handler must still reflect the queued backlog. + ws.onclose = () => resolve({ beforeClose, onClose: ws.bufferedAmount }); + ws.onerror = () => {}; + const { beforeClose: queued, onClose } = await promise; + + expect(queued).toBeGreaterThan(64 * 1024); + expect(onClose).toBe(queued); + } finally { + server.close(); + } + }); }); From c50b526dc98850ccc28cfced3c74b7a8fc4649ae Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:11:14 +0000 Subject: [PATCH 06/13] test: relax abrupt-close bufferedAmount assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The abrupt-close test read the backlog in onopen and compared it for exact equality with the value seen in the onclose handler. A few frames can legitimately drain from the send buffer between those two points, so the exact match was flaky (e.g. 261635688 vs 261930132). Assert the backlog stays large (not reset to 0) instead — which is the actual spec-conformance property and still fails when bufferedAmount is 0. --- test/js/web/websocket/websocket-buffered-amount.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/js/web/websocket/websocket-buffered-amount.test.ts b/test/js/web/websocket/websocket-buffered-amount.test.ts index 01f3b00eea0..b4142f8fb94 100644 --- a/test/js/web/websocket/websocket-buffered-amount.test.ts +++ b/test/js/web/websocket/websocket-buffered-amount.test.ts @@ -167,7 +167,10 @@ describe("WebSocket.bufferedAmount (client)", () => { const { beforeClose: queued, onClose } = await promise; expect(queued).toBeGreaterThan(64 * 1024); - expect(onClose).toBe(queued); + // Must not reset to 0 on the abrupt close: the backlog is still queued. + // (Not an exact match — a few frames may drain between the read above and + // the close, so assert it stays a large backlog rather than an exact value.) + expect(onClose).toBeGreaterThan(64 * 1024); } finally { server.close(); } From f704b190fcab0a1c3110e6907172ff85b2a8642a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:13:50 +0000 Subject: [PATCH 07/13] websocket: avoid whole-struct tunnel borrow in buffered_amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebSocket::buffered_amount() summed the proxy tunnel's backlog via tunnel.as_ref().buffered_amount(), forming a whole-struct &WebSocketProxyTunnel. But buffered_amount() is now called from fail(), which is reachable from inside the tunnel's SSL-wrapper callbacks (wss:// through an HTTP proxy, on an abrupt close during the connected phase): there a &mut SslWrapper over the tunnel's wrapper field is live, and a whole-struct &Self overlaps it — UB under Stacked Borrows, which the module's Aliasing model doc explicitly forbids for callbacks. Make WebSocketProxyTunnel::buffered_amount take *const Self and project to write_buffer via addr_of!, matching the write_encrypted pattern, and call it through tunnel.as_ptr(). This keeps the tunnel contribution in the count without touching wrapper's bytes. --- src/http_jsc/websocket_client.rs | 9 +++++++-- .../websocket_client/WebSocketProxyTunnel.rs | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index 77cae308e03..b58631ed216 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -2109,8 +2109,13 @@ impl WebSocket { pub fn buffered_amount(&self) -> usize { let mut buffered = self.send_buffer.readable_length(); if let Some(tunnel) = &self.proxy_tunnel { - // SAFETY: `tunnel` holds a live ref (RefPtr has no `Deref`). - buffered += unsafe { tunnel.as_ref() }.buffered_amount(); + // Use the raw-ptr accessor, not `tunnel.as_ref()`: this runs inside + // the tunnel's SSL-wrapper callbacks on an abrupt close (fail()), + // where a whole-struct `&WebSocketProxyTunnel` would overlap the + // live `&mut SslWrapper` over its `wrapper` field (UB under Stacked + // Borrows — see WebSocketProxyTunnel's Aliasing model doc). + // SAFETY: `tunnel` (NonNull) points to a live tunnel. + buffered += unsafe { WebSocketProxyTunnel::buffered_amount(tunnel.as_ptr()) }; } buffered } diff --git a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs index 3af77882766..1f61ebbe7fc 100644 --- a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs +++ b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs @@ -602,8 +602,19 @@ impl WebSocketProxyTunnel { } /// Encrypted bytes still buffered in the tunnel awaiting a writable socket. - pub(crate) fn buffered_amount(&self) -> usize { - self.write_buffer.size() + /// + /// Takes `*const Self` and projects to `write_buffer` via `addr_of!` rather + /// than forming a whole-struct `&Self`: this is reachable from inside the + /// SSL-wrapper callbacks (abrupt close during the connected phase), which + /// hold a `&mut SslWrapper` over the `wrapper` field — a whole-struct borrow + /// would overlap it (see the module's Aliasing model doc). + /// + /// # Safety + /// `this` must point to a live `WebSocketProxyTunnel`. + pub(crate) unsafe fn buffered_amount(this: *const Self) -> usize { + // SAFETY: `this` is live; short-lived shared borrow of the disjoint + // `write_buffer` field only (never touches `wrapper`). + unsafe { (*ptr::addr_of!((*this).write_buffer)).size() } } } From 4d505321e9eb798863140017c85b6d3ead0d4073 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:23:29 +0000 Subject: [PATCH 08/13] websocket: fix bufferedAmount on server close + re-entrant getter + tunnel write-fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more edge cases in the bufferedAmount implementation: 1. Server-initiated graceful close (peer sends a Close frame, client echoes it) reset bufferedAmount to 0: send_close_with_body() calls clear_data() before dispatch_close(), and WebSocket__didClose hardcoded didClose(0, ...). Snapshot the backlog before clear_data() and thread it through dispatch_close -> did_close -> WebSocket__didClose -> didClose as a separate bufferedAmountSnapshot param (NOT unhandledBufferedAmount, so wasClean stays true for a cleanly-completed close handshake). 2. get_buffered_amount() formed a whole-struct &*this on the WebSocket allocation, but the C++ bufferedAmount getter runs re-entrantly while a &mut Self is live (JS reads ws.bufferedAmount inside an onmessage handler dispatched from dispatch_data(&mut self)) — UB under Stacked Borrows. Make buffered_amount take *const Self and project to send_buffer/proxy_tunnel via addr_of!, same shape as the f704b190 tunnel fix. 3. On the tunnel FailedToWrite path, send_buffer_out() drop()ed the swapped-out backlog before terminate() -> fail(), so fail()'s snapshot read 0. Restore self.send_buffer = buf first; clear_data() frees it immediately after. Adds a server-initiated-close test. --- src/http_jsc/websocket_client.rs | 59 ++++++++++++------ src/http_jsc/websocket_client/CppWebSocket.rs | 14 ++++- src/jsc/bindings/webcore/WebSocket.cpp | 21 +++++-- src/jsc/bindings/webcore/WebSocket.h | 2 +- .../websocket-buffered-amount.test.ts | 62 +++++++++++++++++++ 5 files changed, 130 insertions(+), 28 deletions(-) diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index b58631ed216..9661911bc27 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -265,7 +265,9 @@ impl WebSocket { // close event fires synchronously inside it, yet the send buffer is // not freed until cancel() below, so C++ must be told the amount now // (it cannot query the connection across this &mut self borrow). - let buffered = self.buffered_amount(); + // SAFETY: `self` is a live `&mut Self`; buffered_amount only does + // short-lived raw-ptr field reads. + let buffered = unsafe { Self::buffered_amount(self) }; CppWebSocket::opaque_ref(ws.as_ptr()).did_abrupt_close(code, buffered); // SAFETY: `self: &mut Self` → `*mut Self`; allocation kept live by // the socket/tunnel I/O ref (or by caller's guard). @@ -1285,9 +1287,11 @@ impl WebSocket { true } Err(true) => { - // `terminate → clear_data` resets `send_buffer`; drop the - // taken fifo without restoring. - drop(buf); + // Restore the backlog before terminating: fail() snapshots + // send_buffer.readable_length() for bufferedAmount, so it must + // still be here. `terminate → cancel → clear_data` frees it + // immediately afterward, so this does not leak. + self.send_buffer = buf; self.terminate(ErrorCode::FailedToWrite); false } @@ -1412,8 +1416,14 @@ impl WebSocket { let slice = &final_body_bytes[..slice_len]; if self.enqueue_encoded_bytes(slice) { + // Snapshot the unsent backlog before clear_data() frees it, so the + // JS close event does not see bufferedAmount reset to 0 (spec: it + // does not reset once the connection closes). + // SAFETY: `self` is a live `&mut Self`; buffered_amount only does + // short-lived raw-ptr field reads. + let buffered = unsafe { Self::buffered_amount(self) }; self.clear_data(); - self.dispatch_close(dispatch_code.unwrap_or(code), &mut reason); + self.dispatch_close(dispatch_code.unwrap_or(code), &mut reason, buffered); } } @@ -1640,20 +1650,22 @@ impl WebSocket { // Capture the unsent backlog before the call (it may already be 0 if a // caller such as handle_close() cleared the buffer first) so C++ can // keep bufferedAmount from resetting to 0 on abrupt close. - let buffered = self.buffered_amount(); + // SAFETY: `self` is a live `&mut Self`; buffered_amount only does + // short-lived raw-ptr field reads. + let buffered = unsafe { Self::buffered_amount(self) }; CppWebSocket::opaque_ref(out.as_ptr()).did_abrupt_close(code, buffered); // SAFETY: `self: &mut Self` → `*mut Self`; allocation kept live by // caller's ref guard (see cancel/handle_close). unsafe { Self::deref(self) }; } - fn dispatch_close(&mut self, code: u16, reason: &mut bun_core::String) { + fn dispatch_close(&mut self, code: u16, reason: &mut bun_core::String, buffered_amount: usize) { let Some(out) = self.outgoing_websocket.take() else { return; }; self.poll_ref.unref(Self::vm_loop_ctx(&self.global_this)); jsc::mark_binding!(); - CppWebSocket::opaque_ref(out.as_ptr()).did_close(code, reason); + CppWebSocket::opaque_ref(out.as_ptr()).did_close(code, reason, buffered_amount); // SAFETY: `self: &mut Self` → `*mut Self`; allocation kept live by // caller's ref guard. unsafe { Self::deref(self) }; @@ -2106,14 +2118,25 @@ impl WebSocket { /// Backs the client `WebSocket.bufferedAmount` getter. Includes the framing /// bytes of buffered frames (the send buffer holds fully framed messages), /// plus any encrypted bytes the proxy tunnel still holds. - pub fn buffered_amount(&self) -> usize { - let mut buffered = self.send_buffer.readable_length(); - if let Some(tunnel) = &self.proxy_tunnel { - // Use the raw-ptr accessor, not `tunnel.as_ref()`: this runs inside - // the tunnel's SSL-wrapper callbacks on an abrupt close (fail()), - // where a whole-struct `&WebSocketProxyTunnel` would overlap the - // live `&mut SslWrapper` over its `wrapper` field (UB under Stacked - // Borrows — see WebSocketProxyTunnel's Aliasing model doc). + /// + /// Takes `*const Self` and projects to `send_buffer`/`proxy_tunnel` via + /// `addr_of!` rather than forming a whole-struct `&Self`: the C++ + /// `bufferedAmount` getter can run re-entrantly while a `&mut Self` is live + /// (JS reads `ws.bufferedAmount` inside an `onmessage` handler dispatched + /// from `dispatch_data(&mut self)`), and a whole-struct `&Self` would pop + /// that borrow's Unique tag (UB under Stacked Borrows). + /// + /// # Safety + /// `this` must point to a live `WebSocket`. + pub unsafe fn buffered_amount(this: *const Self) -> usize { + // SAFETY: `this` is live; short-lived shared borrows of the disjoint + // `send_buffer` and `proxy_tunnel` fields only. + let mut buffered = unsafe { (*core::ptr::addr_of!((*this).send_buffer)).readable_length() }; + if let Some(tunnel) = unsafe { *core::ptr::addr_of!((*this).proxy_tunnel) } { + // Raw-ptr accessor, not `tunnel.as_ref()`: reachable inside the + // tunnel's SSL-wrapper callbacks on abrupt close, where a + // whole-struct `&WebSocketProxyTunnel` would overlap the live + // `&mut SslWrapper` (see WebSocketProxyTunnel's Aliasing model doc). // SAFETY: `tunnel` (NonNull) points to a live tunnel. buffered += unsafe { WebSocketProxyTunnel::buffered_amount(tunnel.as_ptr()) }; } @@ -2123,8 +2146,8 @@ impl WebSocket { // `extern "C"` entrypoint; `this` is non-null by C++ contract (see SAFETY comment below). #[allow(clippy::not_unsafe_ptr_arg_deref)] pub extern "C" fn get_buffered_amount(this: *const Self) -> usize { - // SAFETY: called from C++ with a valid pointer - unsafe { &*this }.buffered_amount() + // SAFETY: called from C++ with a valid pointer. + unsafe { Self::buffered_amount(this) } } } diff --git a/src/http_jsc/websocket_client/CppWebSocket.rs b/src/http_jsc/websocket_client/CppWebSocket.rs index 1474a759810..cb82b0c13a5 100644 --- a/src/http_jsc/websocket_client/CppWebSocket.rs +++ b/src/http_jsc/websocket_client/CppWebSocket.rs @@ -49,7 +49,12 @@ unsafe extern "C" { reason: ErrorCode, buffered_amount: usize, ); - fn WebSocket__didClose(websocket_context: &CppWebSocket, code: u16, reason: *const BunString); + fn WebSocket__didClose( + websocket_context: &CppWebSocket, + code: u16, + reason: *const BunString, + buffered_amount: usize, + ); fn WebSocket__didReceiveText( websocket_context: &CppWebSocket, clone: bool, @@ -85,13 +90,16 @@ impl CppWebSocket { event_loop.exit(); } - pub(crate) fn did_close(&self, code: u16, reason: &mut BunString) { + /// `buffered_amount` is the sender's unsent backlog captured *before* this + /// call (the send buffer is freed during close teardown), so C++ can keep + /// `WebSocket.bufferedAmount` from resetting to 0 once closed. + pub(crate) fn did_close(&self, code: u16, reason: &mut BunString, buffered_amount: usize) { // SAFETY: VirtualMachine::get() returns the live current-thread VM; // event_loop() yields its raw event-loop pointer (live for VM lifetime). let event_loop = VirtualMachine::get().event_loop_mut(); event_loop.enter(); // SAFETY: self is a valid C++ WebCore::WebSocket; reason outlives the call. - unsafe { WebSocket__didClose(self, code, reason) }; + unsafe { WebSocket__didClose(self, code, reason, buffered_amount) }; event_loop.exit(); } diff --git a/src/jsc/bindings/webcore/WebSocket.cpp b/src/jsc/bindings/webcore/WebSocket.cpp index 3fd9ec45b07..47b228237f0 100644 --- a/src/jsc/bindings/webcore/WebSocket.cpp +++ b/src/jsc/bindings/webcore/WebSocket.cpp @@ -1594,7 +1594,7 @@ void WebSocket::didStartClosingHandshake() // }); } -void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code, const String& reason) +void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code, const String& reason, size_t bufferedAmountSnapshot) { // LOG(Network, "WebSocket %p didClose()", this); if (this->m_connectedWebSocketKind == ConnectedWebSocketKind::None) @@ -1614,11 +1614,15 @@ void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code, bool wasClean = m_state == CLOSING && !unhandledBufferedAmount && code != 0; // WebSocketChannel::CloseEventCodeAbnormalClosure; m_state = CLOSED; - // Don't reset the backlog: close()/terminate() already snapshotted the - // unsent bytes into m_bufferedAmount, and the spec requires bufferedAmount - // not to drop to 0 once closed. Keep whichever is larger. + // Don't reset the backlog: the unsent bytes at close time were snapshotted + // (by close()/terminate() into m_bufferedAmount, or passed here via + // bufferedAmountSnapshot for the peer-initiated close handshake). The spec + // requires bufferedAmount not to drop to 0 once closed, so keep the largest. + unsigned snapshot = clampToUnsigned(bufferedAmountSnapshot); if (unhandledBufferedAmount > m_bufferedAmount) m_bufferedAmount = unhandledBufferedAmount; + if (snapshot > m_bufferedAmount) + m_bufferedAmount = snapshot; ASSERT(scriptExecutionContext()); this->m_connectedWebSocketKind = ConnectedWebSocketKind::None; this->m_upgradeClient = nullptr; @@ -1929,7 +1933,7 @@ extern "C" void WebSocket__didAbruptClose(WebCore::WebSocket* webSocket, Bun::We { webSocket->didFailWithErrorCode(errorCode, bufferedAmount); } -extern "C" void WebSocket__didClose(WebCore::WebSocket* webSocket, uint16_t errorCode, BunString* reason) +extern "C" void WebSocket__didClose(WebCore::WebSocket* webSocket, uint16_t errorCode, BunString* reason, size_t bufferedAmount) { WTF::String wtf_reason = reason->transferToWTFString(); // The Rust client only calls this after a completed close handshake @@ -1937,8 +1941,13 @@ extern "C" void WebSocket__didClose(WebCore::WebSocket* webSocket, uint16_t erro // server-initiated close m_state is still OPEN here; transition to // CLOSING so didClose() reports wasClean = true. Abnormal closes go // through WebSocket__didAbruptClose instead. + // + // Pass the queued backlog as bufferedAmountSnapshot (not as + // unhandledBufferedAmount): this close handshake completed cleanly, so + // wasClean must stay true regardless of any application data still queued, + // but bufferedAmount must not reset to 0 (spec). webSocket->didStartClosingHandshake(); - webSocket->didClose(0, errorCode, WTF::move(wtf_reason)); + webSocket->didClose(0, errorCode, WTF::move(wtf_reason), bufferedAmount); } extern "C" void WebSocket__didReceiveText(WebCore::WebSocket* webSocket, bool clone, const ZigString* str) diff --git a/src/jsc/bindings/webcore/WebSocket.h b/src/jsc/bindings/webcore/WebSocket.h index 6dae06d2b41..48db52fb9fb 100644 --- a/src/jsc/bindings/webcore/WebSocket.h +++ b/src/jsc/bindings/webcore/WebSocket.h @@ -199,7 +199,7 @@ class WebSocket final : public RefCounted, public EventTargetWithInli void didConnect(); void disablePendingActivity(); void didStartClosingHandshake(); - void didClose(unsigned unhandledBufferedAmount, unsigned short code, const String& reason); + void didClose(unsigned unhandledBufferedAmount, unsigned short code, const String& reason, size_t bufferedAmountSnapshot = 0); void didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx); void didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params); void didFailWithErrorCode(Bun::WebSocketErrorCode code, size_t bufferedAmount = 0); diff --git a/test/js/web/websocket/websocket-buffered-amount.test.ts b/test/js/web/websocket/websocket-buffered-amount.test.ts index b4142f8fb94..e0a62562255 100644 --- a/test/js/web/websocket/websocket-buffered-amount.test.ts +++ b/test/js/web/websocket/websocket-buffered-amount.test.ts @@ -52,6 +52,13 @@ function maskedServerFrame(): Buffer { return Buffer.concat([header, masked]); } +// A valid (unmasked) server Close frame with status 1000. Triggers the client's +// graceful close handshake (echo Close), not the abrupt-close path. +function serverCloseFrame(): Buffer { + // FIN + opcode 0x8 (close), unmasked, 2-byte payload = status code 1000. + return Buffer.from([0x88, 0x02, 0x03, 0xe8]); +} + describe("WebSocket.bufferedAmount (client)", () => { test("reflects the backlog queued to a peer that stopped reading", async () => { const { port, close } = await nonDrainingServer(); @@ -175,4 +182,59 @@ describe("WebSocket.bufferedAmount (client)", () => { server.close(); } }); + + // The server-initiated graceful close (peer sends a Close frame, client echoes + // it) is a fourth close path. It must also preserve the backlog rather than + // reset bufferedAmount to 0. + test("does not reset to 0 on a server-initiated close while a backlog is queued", async () => { + const { promise: ready, resolve: onReady } = Promise.withResolvers(); + const server = net.createServer(sock => { + let buf = ""; + let upgraded = false; + sock.on("data", d => { + if (upgraded) return; + buf += d.toString("latin1"); + if (!buf.includes("\r\n\r\n")) return; + const key = /sec-websocket-key:\s*(.+)\r\n/i.exec(buf)?.[1]?.trim() ?? ""; + const accept = crypto + .createHash("sha1") + .update(key + WS_MAGIC) + .digest("base64"); + sock.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`, + ); + upgraded = true; + // Stop reading so the client's sends pile up, then send a valid Close + // frame to initiate a graceful close handshake. + sock.pause(); + sock.write(serverCloseFrame()); + }); + sock.on("error", () => {}); + }); + server.listen(0, "127.0.0.1", () => onReady((server.address() as net.AddressInfo).port)); + const port = await ready; + + try { + const ws = new WebSocket(`ws://127.0.0.1:${port}/`); + const { promise, resolve } = Promise.withResolvers<{ beforeClose: number; onClose: number }>(); + let beforeClose = 0; + ws.onopen = () => { + const chunk = Buffer.alloc(64 * 1024, 0x7c).toString(); + for (let i = 0; i < 4000; i++) ws.send(chunk); + beforeClose = ws.bufferedAmount; + }; + ws.onclose = () => resolve({ beforeClose, onClose: ws.bufferedAmount }); + ws.onerror = () => {}; + const { beforeClose: queued, onClose } = await promise; + + expect(queued).toBeGreaterThan(64 * 1024); + // The backlog must survive the server-initiated close. + expect(onClose).toBeGreaterThan(64 * 1024); + } finally { + server.close(); + } + }); }); From 64047c2eb69bc4085c1c77340554e5fea5eb34a7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:36:26 +0000 Subject: [PATCH 09/13] websocket: preserve bufferedAmount on raw socket close; fix clippy - handle_close() (peer RST / terminal socket-close callback) cleared the send buffer before dispatch_abrupt_close(), so bufferedAmount reset to 0 in the close event. Snapshot the backlog before clear_data() and pass it through a new Option override on dispatch_abrupt_close(); other callers pass None and keep snapshotting the live buffer. - Split the buffered_amount() unsafe blocks so each has its own SAFETY comment (clippy::undocumented_unsafe_blocks), fixing the cargo clippy lane. - Add a raw-socket-reset test. --- src/http_jsc/websocket_client.rs | 40 +++++++++----- .../websocket-buffered-amount.test.ts | 55 +++++++++++++++++++ 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index 9661911bc27..a7f08dbe43c 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -253,7 +253,7 @@ impl WebSocket { // the destructor's finalize() — does not leak. When reached via // fail(), outgoing_websocket is already None and this is a no-op. if had_tunnel { - this.dispatch_abrupt_close(ErrorCode::Ended); + this.dispatch_abrupt_close(ErrorCode::Ended, None); } } @@ -345,10 +345,15 @@ impl WebSocket { pub fn handle_close(&mut self, _socket: Socket, _code: c_int, _reason: *mut c_void) { log!("onClose"); jsc::mark_binding!(); + // Snapshot the backlog before clear_data() frees it, so the close event + // does not see bufferedAmount reset to 0 (e.g. peer RST with unsent + // frames). SAFETY: `self` is a live `&mut Self`; buffered_amount only + // does short-lived raw-ptr field reads. + let buffered = unsafe { Self::buffered_amount(self) }; self.clear_data(); self.tcp.detach(); - self.dispatch_abrupt_close(ErrorCode::Ended); + self.dispatch_abrupt_close(ErrorCode::Ended, Some(buffered)); // For the socket. // SAFETY: `self: &mut Self` → `*mut Self`; this is the terminal @@ -1304,7 +1309,7 @@ impl WebSocket { fn send_pong(&mut self) -> bool { if !self.has_tcp() { - self.dispatch_abrupt_close(ErrorCode::Ended); + self.dispatch_abrupt_close(ErrorCode::Ended, None); return false; } @@ -1364,7 +1369,7 @@ impl WebSocket { let body_len = body_len.min(123); log!("Sending close with code {}", code); if !self.has_tcp() { - self.dispatch_abrupt_close(ErrorCode::Ended); + self.dispatch_abrupt_close(ErrorCode::Ended, None); self.clear_data(); return; } @@ -1481,7 +1486,7 @@ impl WebSocket { let this = unsafe { &mut *this_ptr }; if !this.has_tcp() || op > 0xF { - this.dispatch_abrupt_close(ErrorCode::Ended); + this.dispatch_abrupt_close(ErrorCode::Ended, None); return; } @@ -1527,7 +1532,7 @@ impl WebSocket { let this = unsafe { &mut *this_ptr }; if !this.has_tcp() || op > 0xF { - this.dispatch_abrupt_close(ErrorCode::Ended); + this.dispatch_abrupt_close(ErrorCode::Ended, None); return; } @@ -1570,7 +1575,7 @@ impl WebSocket { let _ = this.send_data(bytes, !this.has_backpressure(), opcode); } else { // Invalid blob, close connection - this.dispatch_abrupt_close(ErrorCode::Ended); + this.dispatch_abrupt_close(ErrorCode::Ended, None); } } @@ -1588,7 +1593,7 @@ impl WebSocket { // SAFETY: str_ is a valid pointer from C++ let str = unsafe { &*str_ }; if !this.has_tcp() { - this.dispatch_abrupt_close(ErrorCode::Ended); + this.dispatch_abrupt_close(ErrorCode::Ended, None); return; } @@ -1641,18 +1646,20 @@ impl WebSocket { ); } - fn dispatch_abrupt_close(&mut self, code: ErrorCode) { + /// `buffered_override` lets a caller that already cleared the send buffer + /// (e.g. `handle_close()` calls `clear_data()` first) pass the backlog it + /// captured beforehand. `None` snapshots the live send buffer here. + fn dispatch_abrupt_close(&mut self, code: ErrorCode, buffered_override: Option) { let Some(out) = self.outgoing_websocket.take() else { return; }; self.poll_ref.unref(Self::vm_loop_ctx(&self.global_this)); jsc::mark_binding!(); - // Capture the unsent backlog before the call (it may already be 0 if a - // caller such as handle_close() cleared the buffer first) so C++ can - // keep bufferedAmount from resetting to 0 on abrupt close. + // Capture the unsent backlog so C++ can keep bufferedAmount from + // resetting to 0 on abrupt close. // SAFETY: `self` is a live `&mut Self`; buffered_amount only does // short-lived raw-ptr field reads. - let buffered = unsafe { Self::buffered_amount(self) }; + let buffered = buffered_override.unwrap_or_else(|| unsafe { Self::buffered_amount(self) }); CppWebSocket::opaque_ref(out.as_ptr()).did_abrupt_close(code, buffered); // SAFETY: `self: &mut Self` → `*mut Self`; allocation kept live by // caller's ref guard (see cancel/handle_close). @@ -2130,9 +2137,12 @@ impl WebSocket { /// `this` must point to a live `WebSocket`. pub unsafe fn buffered_amount(this: *const Self) -> usize { // SAFETY: `this` is live; short-lived shared borrows of the disjoint - // `send_buffer` and `proxy_tunnel` fields only. + // `send_buffer` and `proxy_tunnel` fields only (never a whole-struct + // `&Self`, which could overlap a live `&mut Self` on a re-entrant call). let mut buffered = unsafe { (*core::ptr::addr_of!((*this).send_buffer)).readable_length() }; - if let Some(tunnel) = unsafe { *core::ptr::addr_of!((*this).proxy_tunnel) } { + // SAFETY: as above — `proxy_tunnel` is `Copy` (Option>). + let tunnel = unsafe { *core::ptr::addr_of!((*this).proxy_tunnel) }; + if let Some(tunnel) = tunnel { // Raw-ptr accessor, not `tunnel.as_ref()`: reachable inside the // tunnel's SSL-wrapper callbacks on abrupt close, where a // whole-struct `&WebSocketProxyTunnel` would overlap the live diff --git a/test/js/web/websocket/websocket-buffered-amount.test.ts b/test/js/web/websocket/websocket-buffered-amount.test.ts index e0a62562255..c179c33bfe0 100644 --- a/test/js/web/websocket/websocket-buffered-amount.test.ts +++ b/test/js/web/websocket/websocket-buffered-amount.test.ts @@ -237,4 +237,59 @@ describe("WebSocket.bufferedAmount (client)", () => { server.close(); } }); + + // A raw socket close/reset (no Close handshake) while a backlog is queued must + // also preserve bufferedAmount. This exercises the socket-close callback path, + // which frees the send buffer before dispatching the close event. + test("does not reset to 0 on a raw socket reset while a backlog is queued", async () => { + const { promise: ready, resolve: onReady } = Promise.withResolvers(); + const server = net.createServer(sock => { + let buf = ""; + let upgraded = false; + sock.on("data", d => { + if (upgraded) return; + buf += d.toString("latin1"); + if (!buf.includes("\r\n\r\n")) return; + const key = /sec-websocket-key:\s*(.+)\r\n/i.exec(buf)?.[1]?.trim() ?? ""; + const accept = crypto + .createHash("sha1") + .update(key + WS_MAGIC) + .digest("base64"); + sock.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`, + ); + upgraded = true; + // Stop reading so the client's sends pile up, then abruptly destroy + // the connection (RST) — no Close handshake. + sock.pause(); + sock.destroy(); + }); + sock.on("error", () => {}); + }); + server.listen(0, "127.0.0.1", () => onReady((server.address() as net.AddressInfo).port)); + const port = await ready; + + try { + const ws = new WebSocket(`ws://127.0.0.1:${port}/`); + const { promise, resolve } = Promise.withResolvers<{ beforeClose: number; onClose: number }>(); + let beforeClose = 0; + ws.onopen = () => { + const chunk = Buffer.alloc(64 * 1024, 0x7d).toString(); + for (let i = 0; i < 4000; i++) ws.send(chunk); + beforeClose = ws.bufferedAmount; + }; + ws.onclose = () => resolve({ beforeClose, onClose: ws.bufferedAmount }); + ws.onerror = () => {}; + const { beforeClose: queued, onClose } = await promise; + + expect(queued).toBeGreaterThan(64 * 1024); + // The backlog must survive the abrupt socket close. + expect(onClose).toBeGreaterThan(64 * 1024); + } finally { + server.close(); + } + }); }); From c758473acc6f3a278cecba00c01a731eb7096f69 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:05:04 +0000 Subject: [PATCH 10/13] test: relax close() bufferedAmount assertion to >= close() queues an ~8-byte close frame into the send buffer before the getBufferedAmount snapshot, so afterClose is beforeClose + ~8, not exactly beforeClose. The exact-equality assertion flaked off-by-8 on macOS/Windows (it happened to coalesce on Linux loopback). Assert afterClose >= beforeClose, which matches the spec (bufferedAmount does not reset on close and only increases) and still fails if it were reset to 0. --- test/js/web/websocket/websocket-buffered-amount.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/js/web/websocket/websocket-buffered-amount.test.ts b/test/js/web/websocket/websocket-buffered-amount.test.ts index c179c33bfe0..5ecdf30ea2f 100644 --- a/test/js/web/websocket/websocket-buffered-amount.test.ts +++ b/test/js/web/websocket/websocket-buffered-amount.test.ts @@ -114,8 +114,10 @@ describe("WebSocket.bufferedAmount (client)", () => { const { beforeClose, afterClose } = await promise; expect(beforeClose).toBeGreaterThan(64 * 1024); - // The backlog must survive the close() transition. - expect(afterClose).toBe(beforeClose); + // The backlog must survive the close() transition — per spec it does not + // reset to 0 and only increases afterward (close() itself queues an + // ~8-byte close frame, so afterClose is >= beforeClose, not exactly it). + expect(afterClose).toBeGreaterThanOrEqual(beforeClose); } finally { close(); } From 477bb59b3b072298a894be61ca3fb7cb5edd1afc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:44:54 +0000 Subject: [PATCH 11/13] test: clarify abrupt-close test comments sock.destroy() on a drained socket sends FIN, not RST, and which client callback fires (handle_close vs handle_end -> fail) is platform-dependent. Rename the test and soften the comments to describe the abrupt close accurately rather than claiming it deterministically hits the socket-close callback path. --- .../websocket/websocket-buffered-amount.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/js/web/websocket/websocket-buffered-amount.test.ts b/test/js/web/websocket/websocket-buffered-amount.test.ts index 5ecdf30ea2f..ec622924a5f 100644 --- a/test/js/web/websocket/websocket-buffered-amount.test.ts +++ b/test/js/web/websocket/websocket-buffered-amount.test.ts @@ -240,10 +240,11 @@ describe("WebSocket.bufferedAmount (client)", () => { } }); - // A raw socket close/reset (no Close handshake) while a backlog is queued must - // also preserve bufferedAmount. This exercises the socket-close callback path, - // which frees the send buffer before dispatching the close event. - test("does not reset to 0 on a raw socket reset while a backlog is queued", async () => { + // An abrupt socket close (no WebSocket Close handshake) while a backlog is + // queued must also preserve bufferedAmount. Depending on the platform's event + // loop this routes through either handle_close() (socket-close callback) or + // handle_end() -> fail(); both snapshot the backlog before freeing it. + test("does not reset to 0 on an abrupt socket close while a backlog is queued", async () => { const { promise: ready, resolve: onReady } = Promise.withResolvers(); const server = net.createServer(sock => { let buf = ""; @@ -264,8 +265,9 @@ describe("WebSocket.bufferedAmount (client)", () => { `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, ); upgraded = true; - // Stop reading so the client's sends pile up, then abruptly destroy - // the connection (RST) — no Close handshake. + // Stop reading so the client's sends pile up, then abruptly destroy the + // connection (sends FIN; the client's own writes to the closed peer may + // then draw an RST) — no WebSocket Close handshake either way. sock.pause(); sock.destroy(); }); From ddd79785cd6cd157b8a7e215263c8a7f98ce12a9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:24:15 +0000 Subject: [PATCH 12/13] websocket: document the tunnel synchronous-close bufferedAmount gap On the tunnel write-failure path, SslWrapper::write_data can fire on_close -> fail() synchronously inside the write (fatal SSL error), before the send_buffer is restored, so that bufferedAmount snapshot reads 0. Closing it would require aliasing UB or a per-flush copy, and 0 in that narrow fatal-error window is no worse than the pre-feature behavior, so document it as an intentional limitation rather than regress the hot path. --- src/http_jsc/websocket_client.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index a7f08dbe43c..2bb662cb584 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -1296,6 +1296,16 @@ impl WebSocket { // send_buffer.readable_length() for bufferedAmount, so it must // still be here. `terminate → cancel → clear_data` frees it // immediately afterward, so this does not leak. + // + // KNOWN GAP (tunnel only): if the tunnel's SslWrapper::write_data + // hits a fatal SSL error it fires on_close → fail() *synchronously + // inside* the write above — before this restore — so that + // bufferedAmount snapshot reads 0. Reporting the data as + // `self.send_buffer` cannot be kept populated across the write + // without either aliasing UB (the slice handed to write is + // borrowed from it) or an extra per-flush copy; the window is a + // fatal-handshake/close-notify error mid-flush, and 0 there is no + // worse than the pre-feature behavior. self.send_buffer = buf; self.terminate(ErrorCode::FailedToWrite); false From 842b8d5f30da689af3148a036f98f040c3a230f0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:11:33 +0000 Subject: [PATCH 13/13] websocket: fix garbled word in the KNOWN GAP comment Drop the leftover 'Reporting the data as' fragment so the sentence reads as one clause. Comment only. --- src/http_jsc/websocket_client.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index 2bb662cb584..19f0b3347a5 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -1300,12 +1300,11 @@ impl WebSocket { // KNOWN GAP (tunnel only): if the tunnel's SslWrapper::write_data // hits a fatal SSL error it fires on_close → fail() *synchronously // inside* the write above — before this restore — so that - // bufferedAmount snapshot reads 0. Reporting the data as - // `self.send_buffer` cannot be kept populated across the write - // without either aliasing UB (the slice handed to write is - // borrowed from it) or an extra per-flush copy; the window is a - // fatal-handshake/close-notify error mid-flush, and 0 there is no - // worse than the pre-feature behavior. + // bufferedAmount snapshot reads 0. `self.send_buffer` cannot be + // kept populated across the write without either aliasing UB (the + // slice handed to write is borrowed from it) or an extra per-flush + // copy; the window is a fatal-handshake/close-notify error + // mid-flush, and 0 there is no worse than the pre-feature behavior. self.send_buffer = buf; self.terminate(ErrorCode::FailedToWrite); false