Skip to content

Implement client WebSocket.bufferedAmount#31761

Open
robobun wants to merge 13 commits into
mainfrom
farm/91e7f8dc/ws-client-buffered-amount
Open

Implement client WebSocket.bufferedAmount#31761
robobun wants to merge 13 commits into
mainfrom
farm/91e7f8dc/ws-client-buffered-amount

Conversation

@robobun

@robobun robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #31760

Problem

The client WebSocket.bufferedAmount always returns 0, so send backpressure on a client WebSocket is unobservable. Bytes queued by send() to a peer that has stopped reading are genuinely buffered in-process (RSS climbs by the full payload), but bufferedAmount never reflects it — a peer that stops reading causes unbounded in-process buffering with no observable signal.

ServerWebSocket.getBufferedAmount() already works correctly; this was specific to the client WebSocket class.

Cause

WebSocket::bufferedAmount() returned saturateAdd(m_bufferedAmount, m_bufferedAmountAfterClose), but for an OPEN connection both stay 0:

  • m_bufferedAmount was only assigned by didUpdateBufferedAmount(), which nothing ever called, and the lines in the send paths that would have refreshed it from the connection were commented out.
  • m_bufferedAmountAfterClose only accumulates when send() is called after the socket is already CLOSING/CLOSED.

The Rust client never exported a buffered-amount accessor (neither did the original Zig reference), so the value was genuinely unimplemented.

Fix

  • Add Bun__WebSocketClient__getBufferedAmount / Bun__WebSocketClientTLS__getBufferedAmount (Rust FFI) returning the in-process send_buffer readable length — the bytes of fully-framed messages queued but not yet written to the socket — plus any encrypted bytes still held by the proxy tunnel.
  • Query it live from WebSocket::bufferedAmount() for the Client/ClientSSL connection kinds, clamping to unsigned. After close the connection is gone, so the existing m_bufferedAmount fallback (set by didClose()) is preserved.

This mirrors ServerWebSocket::get_buffered_amount, which already queries the live buffer.

Verification

Issue repro (raw TCP server completes the WS handshake then pause()s; client sends 250 MiB):

# before
bufferedAmount=0 maxSeen=0 rssDelta=500MB
# after
bufferedAmount=259644096 maxSeen=259644096 rssDelta=500MB

i.e. ~247 MiB queued, matching Node/undici's ~249 MiB instead of 0.

Test in test/js/web/websocket/websocket-buffered-amount.test.ts asserts the baseline is 0 with nothing queued and grows past a single 64 KiB frame once the peer stops reading. It fails before the change (Received: 0) and passes after. Existing websocket-client and websocket-blob suites still pass.

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.
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds live client bufferedAmount by summing the Rust send buffer and optional proxy-tunnel backlog, exports C‑ABI getters for TLS/non‑TLS, threads buffered snapshots into C++ close/fail callbacks, updates C++ bufferedAmount logic, and adds client tests exercising backlog and close/abort paths.

Changes

WebSocket client bufferedAmount reporting

Layer / File(s) Summary
Proxy tunnel buffering measurement
src/http_jsc/websocket_client/WebSocketProxyTunnel.rs
WebSocketProxyTunnel exposes buffered_amount() returning the encrypted write_buffer size.
Rust WebSocket buffered-amount, snapshotting, and export
src/http_jsc/websocket_client.rs
Adds WebSocket::buffered_amount, snapshots send backlog before teardown, restores send_buffer on send failure, and extends export_websocket_client! to generate Bun__WebSocketClient__getBufferedAmount and TLS variant shims; threads buffered snapshots into dispatch_close/dispatch_abrupt_close.
Rust FFI: C++ callback surface
src/http_jsc/websocket_client/CppWebSocket.rs
Update extern declarations and CppWebSocket::did_abrupt_close / did_close wrappers to accept and forward buffered_amount.
Upgrade client abrupt-close callsite
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
Callsite now supplies explicit zero backlog when invoking did_abrupt_close.
C binding declarations
src/jsc/bindings/headers.h
Declare Bun__WebSocketClient__getBufferedAmount and Bun__WebSocketClientTLS__getBufferedAmount.
C++ WebCore bufferedAmount integration
src/jsc/bindings/webcore/WebSocket.cpp, src/jsc/bindings/webcore/WebSocket.h
Add clamp helper; snapshot native buffered backlog before close()/terminate(); preserve larger buffered amounts across didClose/didFailWithErrorCode; update extern "C" bridges to accept bufferedAmount; compute bufferedAmount() from live native backlog plus post-close accumulation.
Client bufferedAmount tests
test/js/web/websocket/websocket-buffered-amount.test.ts
Add nonDrainingServer, protocol frame helpers, and five tests covering bufferedAmount growth, retention across close(), abrupt-close observation, server-initiated graceful close, and abrupt socket-destroy behavior.
  • Possibly related PRs:

    • oven-sh/bun#31518: Modifies WebSocket close/teardown paths and native-Rust integration at similar callback sites.
    • oven-sh/bun#30722: Overlaps WebSocket client close-path changes that touch similar teardown code.
  • Suggested reviewers:

    • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Implement client WebSocket.bufferedAmount' clearly summarizes the main change: adding the bufferedAmount implementation for client-side WebSocket that was previously returning 0.
Description check ✅ Passed The PR description is comprehensive, covering the problem (bufferedAmount always returns 0), root cause (m_bufferedAmount never updated, Rust client lacked accessor), the fix (new FFI functions + live queries), and verification with reproduction steps and test coverage.
Linked Issues check ✅ Passed The PR fully addresses issue #31760 by implementing client WebSocket.bufferedAmount reporting, making send backpressure observable, providing live buffer queries, and adding comprehensive tests covering baseline and growth scenarios.
Out of Scope Changes check ✅ Passed All changes are within scope: adding buffered-amount accessors to Rust client, updating C++ callback signatures to thread buffered amounts, adding FFI declarations, and comprehensive test coverage. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/http_jsc/websocket_client.rs`:
- Around line 2096-2111: The current get_buffered_amount implementation reports
framed/encrypted wire bytes by calling send_buffer.readable_length() and
proxy_tunnel.buffered_amount(); instead introduce and use a dedicated plaintext
queued-byte counter (e.g., a field like queued_plain_bytes) that is incremented
in the send() enqueue path and decremented in the write/drain completion path
(and when proxied bytes are handed to the tunnel) and ensure proxy_tunnel
contributions are accounted only as the plaintext bytes handed off, then modify
get_buffered_amount to return this counter instead of the framed/encrypted
buffer lengths (update send(), drain/write completion, and any tunnel-handoff
code paths accordingly).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5791622e-7ba5-4831-8a71-b8a4748ecdd5

📥 Commits

Reviewing files that changed from the base of the PR and between d2a6506 and 00deb1d.

📒 Files selected for processing (5)
  • src/http_jsc/websocket_client.rs
  • src/http_jsc/websocket_client/WebSocketProxyTunnel.rs
  • src/jsc/bindings/headers.h
  • src/jsc/bindings/webcore/WebSocket.cpp
  • test/js/web/websocket/websocket-buffered-amount.test.ts

Comment thread src/http_jsc/websocket_client.rs
Comment thread src/jsc/bindings/webcore/WebSocket.cpp Outdated
robobun added 2 commits June 3, 2026 08:10
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().
Comment thread src/jsc/bindings/webcore/WebSocket.cpp
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.
Comment thread src/http_jsc/websocket_client.rs Outdated
Comment thread test/js/web/websocket/websocket-buffered-amount.test.ts
robobun added 2 commits June 3, 2026 10:11
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.
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.
@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI is green on the diff — the only red lane is `darwin-14-x64-test-bun`, failing on `test/js/bun/terminal/terminal.test.ts > creates subprocess with terminal attached` (a PTY test that times out at 90s on loaded macOS runners; unrelated to this PR and previously flaked on the same lane in build #60150). The earlier `serve-body-leak` memory-threshold blip on `debian-13-x64-asan` passed on retry — that lane is green.

All WebSocket coverage passes, including `debian-13-x64-asan` (ASAN, where the tunnel-aliasing fix matters most), `darwin-26-aarch64`, and every Linux/Windows lane. The new `websocket-buffered-amount` tests pass on all lanes.

I have already used my one CI re-roll, so I am not pushing another retrigger. This needs a maintainer to re-run the macOS lane / merge past the unrelated PTY flake.

Comment thread src/http_jsc/websocket_client.rs Outdated
Comment thread src/http_jsc/websocket_client.rs
Comment thread src/http_jsc/websocket_client.rs Outdated
…unnel write-fail

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<SSL>
   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 f704b19 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/http_jsc/websocket_client.rs`:
- Around line 1650-1656: The close-path is still clearing the send backlog
before we snapshot it, so did_abrupt_close sees bufferedAmount == 0; update the
flow so the queued byte count is captured before clear_data() is invoked: call
Self::buffered_amount(self) (or otherwise read the internal send-queue length)
prior to handle_close()/clear_data(), and pass that captured value into
CppWebSocket::opaque_ref(...).did_abrupt_close(code, buffered) (or modify
dispatch_abrupt_close to accept the buffered value) so the abrupt-close event
reports the true bufferedAmount; relevant symbols: Self::buffered_amount,
handle_close, clear_data, dispatch_abrupt_close, CppWebSocket::opaque_ref,
did_abrupt_close.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ae7ac404-3c38-4485-b966-744e0ae8211a

📥 Commits

Reviewing files that changed from the base of the PR and between 83525d3 and 4d50532.

📒 Files selected for processing (7)
  • src/http_jsc/websocket_client.rs
  • src/http_jsc/websocket_client/CppWebSocket.rs
  • src/http_jsc/websocket_client/WebSocketProxyTunnel.rs
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • src/jsc/bindings/webcore/WebSocket.cpp
  • src/jsc/bindings/webcore/WebSocket.h
  • test/js/web/websocket/websocket-buffered-amount.test.ts

Comment thread src/http_jsc/websocket_client.rs Outdated
- 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.
Comment thread test/js/web/websocket/websocket-buffered-amount.test.ts Outdated
Comment thread src/http_jsc/websocket_client.rs Outdated
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.
Comment thread test/js/web/websocket/websocket-buffered-amount.test.ts Outdated
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.
@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green — the only red lane is `darwin-14-x64-test-bun`, failing on unrelated third-party/flaky tests (`grpc-js/test-tonic` — the unzipper-downloaded test server failed to start with FILE_ENDED; plus `bake/serve-plugins-dev-server` and `serve-body-leak` which already passed on retry on the ASAN lane). None of these touch the WebSocket client code this PR changes.

All WebSocket coverage passes, including the new `websocket-buffered-amount` tests on every lane (debian-13-x64-asan, darwin-26-aarch64, all Windows, alpine, ubuntu). The earlier macOS/Windows flake in my own test was fixed in c758473 (relaxed the close() assertion to `>=`, since close() queues an ~8-byte close frame).

I have already used my one CI re-roll (83525d3), so per policy I am not pushing another retrigger. This needs a maintainer to re-run the `darwin-14` lane / merge past the unrelated third-party flake. All 12 review threads (CodeRabbit + claude findings) are resolved.

Comment thread src/http_jsc/websocket_client.rs
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.
@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Still green on the diff. Latest build (#60169) failed only on `darwin-14-x64-test-bun`, and every failing test there is unrelated flake on a different subsystem — `cli/install/migration/complex-workspace`, `cli/update_interactive_install` (package-manager/registry), `http/serve-body-leak` (ASAN memory threshold), and `jsc-stress/simd-baseline` — all tagged flaky/passed-on-retry. My last commit was a comment-only doc change (no logic), so nothing here can be caused by this PR.

The WebSocket `bufferedAmount` tests pass on every lane that finished (debian-13-x64-asan, all Windows lanes, etc.). I have already used my one CI re-roll (83525d3); per policy I am not pushing another retrigger. All 13 review threads are resolved — this just needs a maintainer to merge past the unrelated `darwin-14` flakes.

Comment thread src/http_jsc/websocket_client.rs Outdated
Drop the leftover 'Reporting the data as' fragment so the sentence reads
as one clause. Comment only.
@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Still green on the diff. Build #60171 flagged `debian-13-x64-asan-test-bun`, failing only on unrelated flaky tests — `cli/install/bunx` ("package that requires node 24", an npm-download test) and `node/http/node-http-connect` (tagged flaky) — neither of which touches WebSocket code; that lane is now passing on retry. My last two commits were comment-only (doc wording), so nothing here can be caused by this PR.

The WebSocket `bufferedAmount` tests pass on every lane that finished. I already used my one CI re-roll (83525d3); per policy I am not pushing another. All 14 review threads (CodeRabbit + claude) are resolved and CodeRabbit’s latest review found no actionable comments with all pre-merge checks green. This just needs a maintainer to merge.

@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green; CI build #60171 hit a broad flake wave across many unrelated subsystems — the failing tests are `cli/install/bunx`, `cli/run/no-orphans`, `bun/cron/in-process-cron`, `bun/spawn/spawn-stdin-readable-stream`, `node/http/node-http-connect`, `web/fetch/fetch`, `web/streams/streams-leak`, and `napi/napi`, all tagged flaky/warning. None touch WebSocket code, and the spread across unrelated areas (install, cron, spawn, fetch, napi, …) is characteristic of a CI-infra/registry hiccup, not this PR. My last two commits were comment-only (doc wording), so nothing here can be caused by this change.

The WebSocket `bufferedAmount` tests are not among the failures and pass on every lane. I already used my one CI re-roll (83525d3); per policy I am not pushing another retrigger. All 14 review threads are resolved and CodeRabbit’s latest review found no actionable comments with all pre-merge checks green. This needs a maintainer to re-run CI / merge past the unrelated flake wave.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebSocket.bufferedAmount (client) always returns 0 — backpressure is unobservable

1 participant