Skip to content

Keep client WebSocket.bufferedAmount from resetting to zero after close - #31761

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

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

Conversation

@robobun

@robobun robobun commented Jun 3, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #31760

#40566 implemented the live client bufferedAmount. This PR is now the follow-up that keeps the value correct through close.

Problem

  • After WebSocket client: pause()/resume(), working bufferedAmount, and a ServerWebSocket drain stall fix #40566, bufferedAmount resets to 0 on every close path that drops the native client: terminate(), a protocol-error abort, a server-initiated close, and a peer TCP close. The WHATWG spec says the value "does not reset to zero once the connection closes". didClose (src/jsc/bindings/webcore/WebSocket.cpp:1500) receives no snapshot and didFailWithErrorCode takes no buffered argument, so the queued backlog is lost with the send buffer.
  • The Blob overloads of send()/ping()/pong() and the no-argument ping()/pong() return early after close without adding to m_bufferedAmountAfterClose, unlike the String and ArrayBuffer overloads.

Fix

  • Every Rust close path (fail, handle_close, send_close_with_body, finish_pending_close) snapshots buffered_amount() before clear_data() frees the buffer and passes it through WebSocket__didClose/WebSocket__didAbruptClose. didClose() keeps the largest of the current value and the snapshot, so wasClean stays true on a clean close. terminate() snapshots eagerly in C++ before cancelConnectedClient() detaches the client.
  • The Blob overloads accumulate clampToUnsigned(size) + getFramingOverhead(size) after close. The no-argument ping()/pong() add their 6 framing bytes, matching ping("").
  • Verified: test/js/web/websocket/websocket-buffered-amount.test.ts gains 9 retention tests, 5 of which fail on main. The 7 live-tracking tests from WebSocket client: pause()/resume(), working bufferedAmount, and a ServerWebSocket drain stall fix #40566 still pass, as does the rest of test/js/web/websocket/.

Background

  • The native client frees its send buffer in clear_data() during teardown, so the backlog must be read before that point and carried across the FFI callback.
  • m_bufferedAmount holds the final snapshot once the client is detached. m_bufferedAmountAfterClose counts payloads handed to send()/ping()/pong() after close, which are never written.
Notes

This PR originally implemented the whole client bufferedAmount (live getter plus retention). #40566 landed an independent live implementation, so this branch was rebased down to the retention and post-close accounting delta on top of it. The FFI getter, the tunnel accessor, and the headers.h declarations now come from main. The close-path snapshot threading, the didClose/didFailWithErrorCode max-keeping, the terminate() eager snapshot, and the overload accounting are this PR's remaining content, unchanged from the reviewed versions. Earlier review rounds (memory-safety, aliasing, clamping) all applied to these paths and their findings are preserved.

Known gap (tunnel only, documented in-source): a fatal SSL error inside the tunnel's write_data fires fail() before the send buffer is restored, so that snapshot reads 0. No worse than the pre-feature behavior.


[review] gate passed · iteration 28 · 6 files touched

fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/websocket/websocket-buffered-amount.test.ts
bun test v1.4.1 (adc354d99)

test/js/web/websocket/websocket-buffered-amount.test.ts:
(pass) WebSocket.bufferedAmount (ws) > counts bytes the peer has not accepted and drains to 0 [955.91ms]
(pass) WebSocket.bufferedAmount (wss) > counts bytes the peer has not accepted and drains to 0 [1084.52ms]
(pass) WebSocket.bufferedAmount (wss via http:// proxy) > counts bytes the peer has not accepted and drains to 0 [748.03ms]
(pass) WebSocket.bufferedAmount (wss via https:// proxy) > counts bytes the peer has not accepted and drains to 0 [705.60ms]
(pass) WebSocket.bufferedAmount (ws via https:// proxy) > counts bytes the peer has not accepted and drains to 0 [682.33ms]
(pass) WebSocket.bufferedAmount > is 0 once a small message has been written [56.94ms]
(pass) WebSocket.bufferedAmount > ws package reports the same number [802.02ms]
(pass) WebSocket.bufferedAmount (client) > reflects the backlog queued to a peer that stopped reading [2168.76ms]
(pass) WebSocket.bufferedAmount (client) > 
... (truncated)

release without fix: 15 FAILED
bun test v1.4.1-canary.1 (adc354d99)

test/js/web/websocket/websocket-buffered-amount.test.ts:
107 | 
108 |         for (let i = 0; i < TOTAL; i++) ws.send(CHUNK);
109 | 
110 |         // The kernel and any proxy absorbed some; the rest is ours to report.
111 |         const queued = ws.bufferedAmount;
112 |         expect(queued).toBeGreaterThan(TOTAL_BYTES / 2);
                             ^
error: expect(received).toBeGreaterThan(expected)

Expected: > 33554432
Received: 0

      at <anonymous> (/workspace/bun/test/js/web/websocket/websocket-buffered-amount.test.ts:112:24)
(fail) WebSocket.bufferedAmount (ws) > counts bytes the peer has not accepted and drains to 0 [66.78ms]
107 | 
108 |         for (let i = 0; i < TOTAL; i++) ws.send(CHUNK);
109 | 
110 |         // The kernel and any proxy absorbed some; the rest is ours to report.
111 |         const queued = ws.bufferedAmount;
112 |         expect(queued).toBeGreaterThan(TOTAL_BYTES / 2);
                             ^
error: expect(received).toBeGreaterThan(expected)

Expected: > 33554432
Received: 0

      at <anonymous> (/workspace/bun/test/js/web/websocket/websocket-buffered-amount.test.ts:112:24)
(fail) 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/websocket/websocket-buffered-amount.test.ts
bun test v1.4.1 (adc354d99)

test/js/web/websocket/websocket-buffered-amount.test.ts:
(pass) WebSocket.bufferedAmount (ws) > counts bytes the peer has not accepted and drains to 0 [965.77ms]
(pass) WebSocket.bufferedAmount (wss) > counts bytes the peer has not accepted and drains to 0 [1087.37ms]
(pass) WebSocket.bufferedAmount (wss via http:// proxy) > counts bytes the peer has not accepted and drains to 0 [749.17ms]
(pass) WebSocket.bufferedAmount (wss via https:// proxy) > counts bytes the peer has not accepted and drains to 0 [835.84ms]
(pass) WebSocket.bufferedAmount (ws via https:// proxy) > counts bytes the peer has not accepted and drains to 0 [635.40ms]
(pass) WebSocket.bufferedAmount > is 0 once a small message has been written [52.60ms]
(pass) WebSocket.bufferedAmount > ws package reports the same number [870.11ms]
(pass) WebSocket.bufferedAmount (client) > reflects the backlog queued to a peer that stopped reading [2223.95ms]
(pass) WebSocket.bufferedAmount (client) > 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 645ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/13] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[2/13] cxx obj/unified/UnifiedSource-src_runtime_webview-0.cpp.o
[3/13] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-3.cpp.o
[4/13] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[5/13] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[6/13] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-2.cpp.o
[7/13] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[8/13] gen cpp.rs (cppbind)
[8/13] cargo bun_runtime → libbun_runtime.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunf
... (truncated)
diff hotspot
src/http_jsc/websocket_client.rs                   |  73 +++-
 src/http_jsc/websocket_client/CppWebSocket.rs      |  29 +-
 .../websocket_client/WebSocketUpgradeClient.rs     |   3 +-
 src/jsc/bindings/webcore/WebSocket.cpp             | 100 +++--
 src/jsc/bindings/webcore/WebSocket.h               |   7 +-
 .../websocket/websocket-buffered-amount.test.ts    | 408 ++++++++++++++++++++-
 6 files changed, 569 insertions(+), 51 deletions(-)

gate history · 7 passed · 0 rejected · iteration 28

evidence per changed file
file                                                     reads  edits  tests
src/http_jsc/websocket_client.rs                            39     52     67
src/http_jsc/websocket_client/CppWebSocket.rs                9     13     65
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs      3      3     65
src/jsc/bindings/webcore/WebSocket.cpp                      26     32     67
src/jsc/bindings/webcore/WebSocket.h                         4      5     67
test/js/web/websocket/websocket-buffered-amount.test.ts     27     32     64

root cause · written by the author bot

The client WebSocket's bufferedAmount lost any queued send backlog the moment the connection was torn down, because the native close and failure paths discarded the buffer before the getter could observe it, leaving the value stuck at zero after close, terminate, or an abrupt disconnect. The fix snapshots the native send backlog before teardown, threads that count through the close and error callbacks into the C++ layer, and retains the larger of the snapshot and any previously recorded amount. It also accounts for data queued after close in the Blob and no-argument send overloads, so buffe…

@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
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 coveri…
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…
Title check ✅ Passed The title clearly and concisely describes the primary change: preserving client WebSocket.bufferedAmount after close.
Description check ✅ Passed The description explains the problem, implementation, affected close paths, post-close accounting, verification results, known gap, and test evidence. It does not use the template headings exactly, bu…
Full details: Linked Issues check

Explanation

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.

Full details: Out of Scope Changes check

Explanation

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.

Full details: Description check

Explanation

The description explains the problem, implementation, affected close paths, post-close accounting, verification results, known gap, and test evidence. It does not use the template headings exactly, but it provides the required information and is sufficiently complete.


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

@robobun

robobun commented Jun 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:36 PM PT - Aug 26th, 2026

❌ @autofix-ci[bot], your commit 9ac1675 has 2 failures in Build #106367 (All Failures):

  • test/cli/run/require-cache.test.ts - code 1: error: Memory leak detected on 🐧 13 x64-asan
  • ❌ CPU instruction violation on Windows x64 — 1 check(s) failed
  • The baseline build contains instructions not available on Nehalem (SSE4.2, no AVX/AVX2/AVX512).

    • Static instruction scan

    Static scan violations

    __std_max_4u  [AVX, AVX2]  (16 insns)
    

    If these are runtime-dispatched behind a CPUID gate: add each symbol to scripts/verify-baseline-static/allowlist-x64-windows.txt with a comment pointing at the gate.

    If there's no gate: this is a real bug — a -march leaked into a subbuild.


🧪   To try this PR locally:

bunx bun-pr 31761

That installs a local version of the PR into your bun-31761 executable, so you can run:

bun-31761 --bun

@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 Resolved
Comment thread src/jsc/bindings/webcore/WebSocket.cpp Resolved
Comment thread src/jsc/bindings/webcore/WebSocket.cpp Resolved
Comment thread src/http_jsc/websocket_client.rs Resolved
@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 Resolved
Comment thread src/http_jsc/websocket_client.rs Resolved
Comment thread src/http_jsc/websocket_client.rs Resolved

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

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix the lint failures too

@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Done, pushed in 31104c4.

The red Lint JavaScript check is a repo-wide break, not from this PR's code. #32522 switched the lint script from bunx oxlint (which auto-downloads) to a bare oxlint that resolves from node_modules/.bin, but .github/workflows/lint.yml never runs bun install, so every PR since then fails with oxlint: command not found. I added a bun install step to that workflow (matching format.yml and clippy.yml) and aligned its pinned Bun version to 1.3.2 like those two. bun lint is clean locally after install (0 warnings, 0 errors on src/js).

This PR's own code was already lint-clean: cargo clippy --workspace and the Format job (prettier + clang-format + cargo fmt) both pass, which I also re-verified locally.

Comment thread src/jsc/bindings/webcore/WebSocket.cpp Resolved
@robobun

robobun commented Jun 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status update: rebased onto main after #40566 landed the live client bufferedAmount. This PR is now the close-path follow-up: it keeps the value from resetting to zero after terminate() and abnormal closes, and completes the post-close accounting for the Blob and no-argument overloads.

Comment thread src/jsc/bindings/webcore/WebSocket.cpp Resolved
Comment thread src/jsc/bindings/webcore/WebSocket.cpp Resolved
Comment thread src/http_jsc/websocket_client.rs Resolved
Comment thread src/analytics/lib.rs Resolved
Comment thread src/analytics/lib.rs Resolved
Comment thread src/http_jsc/websocket_client.rs Resolved
Comment thread src/jsc/bindings/webcore/WebSocket.cpp Resolved
Comment thread src/jsc/bindings/webcore/WebSocket.cpp Resolved
@robobun
robobun force-pushed the farm/91e7f8dc/ws-client-buffered-amount branch from 1e37175 to 6094d96 Compare August 24, 2026 04:32

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

I reviewed this PR and the automated bug-hunt found no issues in the current revision. Given the FFI signature changes (WebSocket__didAbruptClose/WebSocket__didClose) and the number of close/teardown paths threaded across the Rust↔C++ boundary, a maintainer sign-off is still warranted.

What was reviewed:

  • Every close path (fail, handle_close, dispatch_close, dispatch_abrupt_close, finish_pending_close, terminate, send_close_with_body) snapshots buffered_amount() before clear_data() frees the send buffer; the C++ side keeps std::max so a stale 0 cannot regress the snapshot.
  • buffered_amount() uses short RefCell::borrow()/JsCell::get() reads only — no re-entrancy hazard from the JS getter inside onmessage; the tunnel accessor is likewise a JsCell read.
  • send_buffer_out()'s Err(true) arm restores buf before terminate() so fail()'s snapshot sees the backlog; the documented tunnel-only gap (synchronous on_close inside write_data) is a no-regression edge case.
  • clampToUnsigned before saturateAdd in the Blob overloads prevents the >4 GiB narrowing wrap; getFramingOverhead(payloadSize) still takes the full size_t so the overhead tier is correct.
Extended reasoning...

Overview

This PR wires up client WebSocket.bufferedAmount, which was previously hard-wired to 0. It spans 8 files: the Rust WebSocket client (websocket_client.rs, CppWebSocket.rs, WebSocketProxyTunnel.rs, WebSocketUpgradeClient.rs), the C++ WebCore layer (WebSocket.cpp/.h), the FFI header (headers.h), and a new 400-line test file. The core additions are (a) two new HOST_EXPORT FFI getters returning the live send-buffer + tunnel-write-buffer size, (b) a buffered_amount: usize parameter threaded through both extern "C" close callbacks so the C++ snapshot survives teardown, and (c) fixes to the Blob and no-arg ping/pong overloads' post-close accounting.

Security risks

None identified. The change is a read-only accessor over an internal buffer plus bookkeeping of a byte counter; no user input parsing, no allocation sizing driven by untrusted data, no auth/TLS-verification changes. The clampToUnsigned helper correctly guards the size_t→unsigned narrowing for the >4 GiB Blob case before it reaches saturateAdd.

Level of scrutiny

High. This is not a mechanical change: it modifies the ABI of two extern "C" functions on both sides of the Rust/C++ boundary, adds two new FFI exports, and touches every close/abort teardown path in the native WebSocket client — code that has historically been sensitive to ordering (snapshot-before-clear_data), re-entrancy (tunnel on_close firing inside write_data), and RefCell borrow discipline. The PR went through 20 iterations with a dozen prior bug-hunter findings (all resolved), and the author bot itself flagged that a maintainer sign-off is needed for the FFI surface. That is well outside the "simple/mechanical" bar for auto-approval.

Other factors

  • All prior inline findings from earlier review rounds are resolved; the current diff reflects the fixes (e.g. the send_buffer.replace(buf) restore before terminate, the buffered_override parameter on dispatch_abrupt_close for the handle_close path, the std::max in didClose).
  • The one deliberately unaddressed edge case (tunnel SslWrapper::write_data fatal error firing on_close synchronously before the buffer restore) is documented in-source as a KNOWN GAP with a stated rationale; it is a no-regression window, not a new bug.
  • Test coverage is thorough (8 cases: live growth, drain-to-0 while open, retention across close()/terminate()/protocol-error/server-Close/abrupt-socket-close, and the Blob/no-arg overloads), all verified failing on main and passing on the branch under both ASAN-debug and release.
  • No outstanding unresolved reviewer comments.

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Nothing new to address in 6094d96: the rebase onto main kept every close-path snapshot, the bufferedAmount tests pass locally (9/9 with the fail-before checks verified against the unfixed build), and all review threads are resolved. The remaining CI red is unrelated to this diff and is reported for triage on main. Agreed on the maintainer sign-off for the FFI surface; the PR is ready for it.

@robobun
robobun force-pushed the farm/91e7f8dc/ws-client-buffered-amount branch 2 times, most recently from d16735c to 5099bb6 Compare August 25, 2026 08:49

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/http_jsc/websocket_client.rs:1323-1325 — Stray double blank line between has_tcp() and write_string() (lines 1323-1324) — rustfmt normalizes consecutive blank lines to one, so cargo fmt --all --check in the Format CI job will fail. Delete one blank line (likely a rebase artifact).

    Extended reasoning...

    What the issue is

    The diff hunk at @@ -1291,12 +1321,13 @@ inserts a lone + line, leaving two consecutive blank lines between the closing brace of has_tcp() and pub(crate) fn write_string(...). rustfmt collapses consecutive blank lines between items to at most one (the default blank_lines_upper_bound = 1), and the repo's rustfmt.toml only sets an ignore list — it does not override that default. The CI Format job runs cargo fmt --all --check via the pinned nightly toolchain, so this will produce a diff and exit non-zero.

    Code path

    In the checked-out file, cat -A shows lines 1323 and 1324 are both empty:

        }$
    $
    $
        pub(crate) fn write_string(this: ThisPtr<Self>, str: &EncodedSlice, op: u8) {$
    

    Running rustfmt --check on the file confirms rustfmt flags exactly this spot:

    Diff in src/http_jsc/websocket_client.rs:1320:
             let tcp = self.tcp.get();
             !tcp.is_closed() && !tcp.is_shutdown()
         }
    -
    
         pub(crate) fn write_string(this: ThisPtr<Self>, str: &EncodedSlice, op: u8) {
    

    Why nothing else prevents it

    rustfmt.toml contains only ignore = ["/vendor", "/test", "/build"]; src/http_jsc/websocket_client.rs is not in an ignored directory, and no blank_lines_upper_bound override is set. The timeline comment claiming "lint/clippy/format are green" was for commit 763114a; the current tip (after two subsequent rebases including 5099bb6 / ebbe330) introduced this stray line — the diff hunk explicitly shows it as a + addition against a context blank.

    Step-by-step proof

    1. rustfmt's default blank_lines_upper_bound is 1 (both stable and nightly).
    2. Repo rustfmt.toml does not override it — only sets ignore for /vendor, /test, /build.
    3. src/http_jsc/websocket_client.rs:1323-1324 are two consecutive empty lines between two fn items in the same impl block.
    4. rustfmt --check on this file emits a diff removing one blank and exits 1 (verified locally, output above).
    5. CI's Format job runs cargo fmt --all --check, which formats every workspace crate root and follows mod declarations — websocket_client.rs is reachable from the bun_http_jsc crate root.
    6. Therefore the Format job fails.

    Impact and fix

    Trivial: the Format CI check goes red until one of the two blank lines is deleted. No runtime behavior is affected. This is almost certainly a rebase artifact from the recent merges onto main (the PR description's "Rebase note" section documents multiple rebases over #40002/#40018/#40055).

    Fix: delete one of the two blank lines at src/http_jsc/websocket_client.rs:1323-1324 (or just run cargo fmt).

@robobun

robobun commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

The stray blank line at src/http_jsc/websocket_client.rs:1323 was a rebase artifact from the removal of write_blob. The autofix commit 69128b4 already deleted it, so the Format check passes on the current tip.

@robobun
robobun force-pushed the farm/91e7f8dc/ws-client-buffered-amount branch from 69128b4 to 9358f56 Compare August 25, 2026 22:51

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

I reviewed this PR and the automated bug-hunt found no issues. Given the FFI ABI change (new size_t bufferedAmount parameter on WebSocket__didClose/WebSocket__didAbruptClose, plus two new getter exports) and the close-path threading across Rust and C++, a maintainer look is still warranted before merge.

What was reviewed:

  • Snapshot ordering on every close path (fail, handle_close, send_close_with_body, finish_pending_close, terminate()) — each reads buffered_amount() before clear_data() frees the buffer.
  • send_buffer_out() Err(true) branch now restores buf before terminate() so fail()'s snapshot is non-zero; clear_data() frees it, so no leak.
  • FFI signatures match on both sides (usize ↔ size_t); clampToUnsigned guards the size_t→unsigned narrowing; didClose/didFailWithErrorCode keep the max so a late 0 cannot regress the snapshot.
  • buffered_amount() re-entrancy from inside onmessage/onclose — uses RefCell::borrow() on send_buffer and JsCell::get() on the tunnel, both shared-borrow-safe under the current &self receiver model.
Extended reasoning...

Overview

This PR implements the client WebSocket.bufferedAmount getter, which was previously hard-wired to 0. It touches 8 files: the Rust WebSocket client (websocket_client.rs, CppWebSocket.rs, WebSocketProxyTunnel.rs, WebSocketUpgradeClient.rs), the C++ WebCore::WebSocket (WebSocket.cpp/.h), the FFI header (headers.h), and a new 400-line test file. The core change is (a) two new HOST_EXPORT getters that read the send buffer's readable length plus the proxy tunnel's write buffer, (b) an added size_t bufferedAmount parameter threaded through both did_close and did_abrupt_close FFI callbacks so every close path snapshots the backlog before clear_data() frees it, and (c) the C++ getter now queries the live client while attached and falls back to m_bufferedAmount once detached. It also brings the Blob and no-arg ping/pong overloads in line with their siblings for post-close accounting.

Security risks

None identified. The new getter is read-only over an existing in-process buffer; no user-controlled input reaches new parsing or allocation. The clampToUnsigned helper prevents a >4 GiB Blob size from wrapping when narrowed to unsigned for saturateAdd.

Level of scrutiny

High. This is not a mechanical change: it modifies the C ABI of two existing extern "C" callbacks (every Rust caller and the C++ implementations must agree on the new arity), and it threads state through every close/teardown path in a refcounted, re-entrant Rust struct where ordering vs. clear_data() matters. The PR has been through 23 review iterations with many aliasing/ordering findings (all resolved), and the author bot itself has twice noted the FFI surface warrants maintainer sign-off.

Other factors

All prior inline review threads (including mine on aliasing, snapshot ordering, and the send_buffer_out restore) are resolved. The test file covers eight distinct close paths (graceful, terminate, abrupt protocol error, server-initiated, socket destroy, drain-while-open, drain-after-close, Blob-after-close) and the gate evidence shows all fail on main and pass on the branch. The one documented gap (tunnel-mode fatal SSL error mid-flush snapshotting 0) is called out in a comment and is no worse than pre-feature behavior. Nothing here is simple enough for auto-approval, but nothing blocks merge either — this is a defer for the FFI/close-path surface, not for an outstanding concern.

@robobun

robobun commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Nothing to address in this round: the rebase over the RefPtr Drop change (#40478) kept every close-path snapshot, the bufferedAmount tests pass locally, and all review threads are resolved. The PR stays ready for a maintainer to sign off on the FFI surface.

robobun and others added 4 commits August 26, 2026 21:03
The client WebSocket.bufferedAmount always returned 0, making send
backpressure unobservable. Export buffered-amount accessors from the
Rust client (send_buffer readable length plus any proxy-tunnel backlog)
and query them live from C++ while the connection exists; thread the
final backlog through dispatch_close/dispatch_abrupt_close into
didClose/didFailWithErrorCode so the close event still reports unsent
bytes instead of resetting to 0. The Blob overloads of send/ping/pong
now accumulate into bufferedAmount after close like their
String/ArrayBuffer/ArrayBufferView siblings.

History note: this branch previously tracked main through merge
commits; GitHub's compare pinned a stale merge-base and showed
thousands of unrelated files, so the branch was rebuilt as this single
commit on top of main. The content is identical to the reviewed tip
(0d28ec8).
- Clamp the Blob payload size before saturateAdd in the post-close
  send/ping/pong(Blob) accounting: a file-backed Blob can exceed 4 GiB
  and the implicit size_t to unsigned narrowing wrapped before the
  saturation check ran.
- Add a terminate() retention test: terminate() is the one close path
  whose backlog snapshot is taken eagerly on the C++ side (cancel
  detaches the client before freeing the send buffer), and no test
  guarded that line; deleting it now fails the new test (reads 0).
- Fail fast in the open-drain test: rewire error/close to reject the
  drain promise and bound the poll on readyState, so an unexpected
  close fails with a message instead of hanging to timeout.
- Update three aliasing comments that still said the SSL-wrapper
  callbacks hold a &mut SslWrapper; the wrapper is all-&self now, and
  the field-projection design follows the tunnel's documented
  convention rather than avoiding a Unique-tag conflict.
@robobun
robobun force-pushed the farm/91e7f8dc/ws-client-buffered-amount branch from 9358f56 to 2cc95b0 Compare August 26, 2026 21:08
@robobun robobun changed the title Implement client WebSocket.bufferedAmount Keep client WebSocket.bufferedAmount from resetting to zero after close Aug 26, 2026

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

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. The reworked diff (close-path retention plus post-close overload accounting on top of #40566) is unchanged since the last push and all review threads are resolved. Ready for a maintainer.

Jarred-Sumner pushed a commit that referenced this pull request Sep 6, 2026
…socket-buffered-amount.test.ts (#41430)

### Problem
- `test/js/web/websocket/websocket-buffered-amount.test.ts` is in the
slowest 5 percent of test files. It takes 13s on the Windows 2019 x64
lane (build 110300) and 8.8s on a local Windows release build.
- Six of its seven tests each send 64 MiB (1024 frames of 64 KiB) to an
origin that does not read. That is 384 MiB per run, most of it through
TLS and an in-process CONNECT proxy. The two `wss via proxy` modes take
4.1s each on Windows.

### Fix
- `sendUntilCounted` sends frames until `bufferedAmount` first rises
above 0 (the kernel is full), then sends 64 more. It asserts that the
count grows by exactly `64 * 65550` bytes: once a frame is queued,
`send_frame` appends every later frame to `send_buffer` without a write
(`src/http_jsc/websocket_client.rs:1257`), so the increment is
deterministic.
- The origin now resolves with its exact byte count. The tests assert it
equals the framed bytes sent, not `>=`. They also assert
`bufferedAmount` is 0 right after `close()`, and that it stays above 0
after one event loop turn while the peer is paused.
- The 60s per-test timeouts are gone. The origin destroys its sockets on
close so a paused peer does not outlive the test.
- Verified: Linux debug `bun bd test`: 7.30s before, 3.48s after (1.86s
of that is import overhead). Linux release: 589ms before, 152ms after.
Windows x64 release: 8.83s before, 190ms after. Five repeat runs on each
platform pass.

### Background
- `WebSocket.bufferedAmount` reports the bytes of framed messages the
client has not written to the socket yet. Bun counts framing (header and
mask), so a 64 KiB binary frame adds 65550 bytes.
- The client writes to the socket on `send()` while nothing is queued.
As soon as a write comes back short, it queues the rest and every later
frame, and drains on the writable event.
- Through an `https://` proxy the client writes through a TLS tunnel.
The first queued bytes are that frame's ciphertext (65660 bytes, record
headers included), which is why the first-queued bound is `2 * FRAME`
and not `FRAME`.

<details><summary>Notes</summary>

Saturation point, release builds:
- Linux loopback: about 41 frames (2.5 MiB) before the first short
write, in every mode. The kernel absorbs nothing more after a tick.
- Windows loopback: 2 to 4 frames. First queued amount is 65550 (the
whole frame, the write returns 0), 124 through an `http://` proxy to a
`wss` origin, and 65660 through an `https://` proxy.

`it.concurrent` was tried and reverted. The origin, proxy, and client
run on one thread, so the file time did not change (3.53s vs 3.48s) and
the interleaving adds risk for no gain.

PR #31761 appends new tests to the end of this file and does not touch
the code this PR changes.
</details>

<!-- robobun:evidence:begin -->

---

**[auto-merge]** gate passed · iteration 0 · 1 files touched

<details><summary>passes on PR (with fix)</summary>

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/web/websocket/websocket-buffered-amount.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/web/websocket/websocket-buffered-amount.test.ts
bun test v1.4.3 (e0a2b82)

test/js/web/websocket/websocket-buffered-amount.test.ts:
(pass) WebSocket.bufferedAmount (ws) > counts bytes the peer has not accepted and drains to 0 [404.85ms]
(pass) WebSocket.bufferedAmount (wss) > counts bytes the peer has not accepted and drains to 0 [285.45ms]
(pass) WebSocket.bufferedAmount (wss via http:// proxy) > counts bytes the peer has not accepted and drains to 0 [146.04ms]
(pass) WebSocket.bufferedAmount (wss via https:// proxy) > counts bytes the peer has not accepted and drains to 0 [130.23ms]
(pass) WebSocket.bufferedAmount (ws via https:// proxy) > counts bytes the peer has not accepted and drains to 0 [97.97ms]
(pass) WebSocket.bufferedAmount > is 0 once a small message has been written [35.44ms]
(pass) WebSocket.bufferedAmount > ws package reports the same number [112.99ms]

 7 pass
 0 fail
 53 expect() calls
Ran 7 tests across 1 file. [3.49s]
Exit: 0
```

</details>

<details><summary>diff hotspot</summary>

```
.../websocket/websocket-buffered-amount.test.ts    | 79 +++++++++++++++-------
 1 file changed, 56 insertions(+), 23 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                     reads  edits  tests
test/js/web/websocket/websocket-buffered-amount.test.ts      1      2     10
```

</details>

**root cause** · written by the author bot

The test file was slow because six of its seven tests each pushed a
fixed 64 MiB through an in-process origin and proxy, roughly 384 MiB per
run with much of it over TLS, far more than needed to observe
backpressure. The fix sends frames only until bufferedAmount first
rises, then sends a known number more and asserts the exact increment,
with the origin reporting its exact byte count for equality checks and a
new assertion that bufferedAmount is 0 after close. This cuts Windows
wall time from 8.83s to 190ms while replacing loose bounds with
deterministic values.

<!-- robobun:evidence:end -->
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

This is Claude, working with Jarred on consolidating the open WebSocket PRs.

Superseded by #45223 (reworked).

Real against the spec text (5 of the 9 tests fail on main), though Node resets bufferedAmount to 0 at close as well, and #31760 is closed.

The connected client now has one exit, finish() in websocket_client.rs, so the snapshot is taken there once and travels as an argument of didClose / didFail: no Option<usize> override, no std::max over three values, no "KNOWN GAP" comment.

After close only the payload bytes of send() count. This PR spread the "payload + framing" rule to Blobs and added 6 bytes per ping(); send("abc") after close was +9 on main, the spec says +3.

Tests: websocket-buffered-amount.test.ts "keeps what was never sent after ..." (4 cases), websocket-close-async-dispatch.test.ts (exactly 8, a Blob adds 4).

Jarred-Sumner added a commit that referenced this pull request Oct 17, 2026
…n native transports (consolidates the open WebSocket PRs) (#45223)

### What does this PR do?

This PR replaces the open WebSocket pull requests (listed in the table at the end) with one change. It covers the global `WebSocket` client, `Bun.serve({ websocket })`, the built-in `ws` package, the parts of `node:http` that `ws` depends on, undici's WebSocket exports, and the types, docs and tests for all of them.

I treated every open PR as a bug report. For each one I reproduced the bug on main, ran the same script on Node 26.3 (undici) and on npm `ws`, checked the RFCs, and then wrote the fix the way the code should have been structured in the first place. Often that meant deleting a mechanism that caused several bugs at once. Many fixes below had no PR. They came out of those comparisons and are marked "(no PR)".

Size: native and builtin source outside `ws` is +5,893 / -5,374. `ws.js` (1,744 lines) becomes `ws.ts` (3,399 lines, about 450 of them types). Tests are +22,739 / -1,786.

How I decided close calls:
- `require("ws")` behaves like npm `ws` on Node.
- The global `WebSocket` behaves like the WHATWG spec and Node's global. Bun's extras stay.
- `Bun.serve` follows RFC 6455 / 7692 on the wire and does what its docs and types say.

### Behavior changes to look at first

These are the changes I expect users to notice. They're listed so that any of them can be vetoed.

**Global `WebSocket`**

1. **Real closing handshake.** Before, `close` fired about 0.2 ms after the `close()` call with our own code and `wasClean: true`, no matter what happened next. Now `close()` sends the Close frame, sets `readyState` to CLOSING and keeps the process alive. Once both Close frames are exchanged, the client closes its side (FIN, close_notify), and `close` fires **when the connection closes**, as in Node (undici and npm `ws`) and browsers. The event has the **server's** code and reason (1005 if its frame was empty) and `wasClean: true`.
   - If the server echoes the Close frame but never closes the connection, the client drops it at the timeout. The close is still clean and still has the server's code.
   - If the connection ends before the server's Close frame, or that frame doesn't arrive within `BUN_CONFIG_WS_CLOSE_TIMEOUT`, the result is `1006`, not clean. The timeout is 30 s, in practice 32 to 36 s because uSockets sweeps every 4 s.
   - Cost: a script that ends in `ws.close()` exits one round trip later. If the server never answers, the process stays alive until the timeout. `terminate()` and `process.exit()` are the ways out. npm `ws` also waits 30 s; undici waits forever.
   - Data queued before `close()` still goes out first. The timeout starts over whenever more of it can be written, so a slow upload isn't cut off (100 MB at 2 MB/s arrives whole, also through a proxy), and a peer that stops reading is dropped one timeout after its last read. Progress means the kernel reports the socket writable: on loopback with the default 30 s, a reader at 20 KB/s counted as stalled and one at 50 KB/s didn't. main waits forever.
   - Two tests from #44618 pinned the old model on purpose. I rewrote them.
2. **`close()` without a code sends an empty Close frame**, and both ends report 1005, as in browsers, Node and npm `ws`. A `Bun.serve` `close(ws, code)` handler now sees 1005 when a Bun client calls `close()`. When the server closes first, the client echoes what it received: 1001 stays 1001, empty stays empty.
3. **One failure path.** Whenever the client fails the connection (bad handshake, TLS, proxy, protocol error, message too big, invalid UTF-8, bad close code), it fires `error`, then `close` with **1006**, reason `""`, not clean. Before, `close` had 1002 / 1007 / 1009 / 1011 / 1015 with an English reason, sometimes `wasClean: true`, and often no `error` fired. The text is now in `ErrorEvent.message`, and `ErrorEvent.error.code` is new (`ECONNREFUSED`, `ENOTFOUND`, `DEPTH_ZERO_SELF_SIGNED_CERT`, `ERR_TLS_CERT_ALTNAME_INVALID`, `WS_ERR_INVALID_UTF8`, ...). If the failure happens after open, the client sends a Close frame with 1002 / 1007 / 1009 first. Losing the TCP connection after open isn't a failure: only `close` 1006 fires, with reason `""` (was "Connection ended").
4. **The 101 response is validated strictly.** The connection fails if the server picks an extension or permessage-deflate parameter that wasn't offered or is invalid (RFC 6455 9.1, RFC 7692 7.1). That includes a server that echoes `client_max_window_bits` without a value. A response head over `--max-http-header-size` + 1 KB now fails no matter how TCP splits it into reads. Before, a head of about 17 KB or more got through if it arrived in one read.
5. **Header values are sent one byte per code unit**, like `fetch` and Node: "café" is `63 61 66 e9` (was UTF-8). `Host` / `Proxy-Connection` in `proxy.headers` replace Bun's own line in the CONNECT request. `fetch` shares that writer, so this changes `fetch` too.
6. `terminate()`, a failed connection and the close timeout now close with a FIN instead of a RST. The kernel still sends a RST if data is unread. The client answers a peer's FIN with a FIN.
7. Messages that arrive together with the 101 are delivered after `open`'s microtasks, as in browsers and undici. npm `ws` has the old order, so `ws` on Bun now differs from it there, in the harmless direction.
8. Smaller changes:
   - An invalid `binaryType` is ignored (used to throw).
   - `ws.url` is `ws:` / `wss:` when the input was `http:` / `https:`.
   - `MessageEvent.origin` is the origin, not the URL with its password.
   - `bufferedAmount` no longer drops to 0 at close.
   - `send(Bun.file())` throws (used to send an empty frame).
   - A ping or pong that nothing listens for is dropped. It used to be queued for a later listener, so a flood of them grew memory to gigabytes. Automatic pongs are unaffected.
   - Interim 1xx responses count toward the response head's size limit (`--max-http-header-size`). An endless stream of `100 Continue` used to freeze the event loop.

**`Bun.serve({ websocket })`**

9. **`websocket.error` is called as `(error, ws)`** and is now typed. It used to be called as `(error)`, so existing handlers keep working and new ones can tell which socket failed. The docs said `(ws, error)`, which never matched the implementation. They now say `(error, ws)`.
10. **`server.stop(true)` closes each WebSocket with 1001 "Server closed"**, including through `using server`. The client and the `close` handler used to see 1006. `stop()` is unchanged.
11. A long `ws.close(code, reason)` reason is cut at 123 bytes on a character boundary. It used to be cut mid-character, so the client saw 1007. Every numeric code is still accepted and sent as before.
12. **`server.upgrade()`**:
    - reads the handshake from `req.headers` only;
    - returns `false` once a `Response` has been accepted for the request;
    - throws for a `Request` that another server received;
    - ignores `Upgrade`, `Connection`, `Sec-WebSocket-Accept / -Key / -Version / -Extensions`, `Content-Length` and `Transfer-Encoding` in `options.headers`;
    - treats an empty `Sec-WebSocket-Protocol` in `options.headers` as declining the subprotocol.
    - Related: an HTTP/1.1 Upgrade or CONNECT request without `Host` gets a 400 (node:http is unchanged).
13. **The configured compressor is actually used.** Every `compress: "dedicated"` / `"4KB"`...`"256KB"` setting ran as `"3KB"`. Each now uses the window its name says, which costs memory. Per connection after traffic: `"dedicated"` 149 → 282 KB, `"128KB"` → 218, `"64KB"` → 186, `"16KB"` → 154. Off, `true` and `"shared"` stay at 2 KB. `"3KB"` is what every setting used to run as. The libdeflate compress fast path is removed (because of the Safari bug described below; numbers are under Performance). `x-webkit-deflate-frame` (Safari 14 and older) is no longer accepted, so those clients run uncompressed. A dedicated compressor accepts and echoes an offer of `server_max_window_bits=8`, as npm `ws` does. The shared compressor (the default) is always 15 bits, so it declines a smaller window, and so does any offer that also has `server_no_context_takeover`. Those connections run uncompressed. Released Bun answered it with 9, which the RFC doesn't allow. (zlib deflates with 9 bits, and that output always inflates with an 8-bit window.)
14. When the server fails a connection, the client now gets a Close frame with 1002 / 1007 / 1009 and the reason. The `close` handler still gets `1006`, but its reason is never empty now. The socket then stays open until the peer's FIN arrives, or for 4 to 16 s.
15. `idleTimeout: 0` arms no timer, so there are no automatic pings either. Those connections used to die at about 256 s.
16. **`maxPayloadLength` no longer applies to Ping, Pong and Close frames.** They have their own 125-byte limit.
17. The error messages of `ServerWebSocket` methods are now consistent with each other. A closed socket still ignores the call, whatever the arguments are.
18. A throw after `server.upgrade()` is no longer swallowed. It goes to `error()` or stderr, the server keeps serving, a surrounding `bun test` fails, and the process exits with code 1 when it ends naturally, same as for any printed `fetch` error. This holds for a sync `fetch`, and for an async one that was really suspended (a timer, I/O) before it called `upgrade()`. If an async `fetch` upgrades before its first real suspension and then rejects, that is still an unhandled rejection and ends the process, as on main.
19. After `server.reload()`, the new `websocket` options also apply to sockets that are already open. The handlers already did.

**`ws`**

20. `'error'` gets an `Error` (was an `ErrorEvent`) and is **fatal without a listener**, as in Node. A failed connection attempt, or on a server a hostile frame, ends a process that has no `'error'` listener.
21. `'close'` is `(code, reason: Buffer)` (was a string plus a third argument). It fires when the connection closes, or at `closeTimeout`. Failures are 1006: `'error'` fires immediately and `'close'` when the connection closes, as in npm. (The global `WebSocket` fires both immediately, as undici does.) `close()` without a code gives 1005. `'unexpected-response'` is not followed by `'close'`.
22. A text message is a `Buffer` for every `binaryType`.
23. `ws` servers have no idle timeout, no automatic pings and no backpressure limit. They used to inherit 120 s, a ping at 104 s and 16 MiB from `Bun.serve`.
    - Automatic Pongs are the one thing still capped at 16 MiB, because the peer controls how many there are. They're skipped while that much is buffered for the socket (RFC 6455 5.5.3; npm queues them all). The cap applies on every transport and also to the clients (`ws` and the global `WebSocket`), which had no cap on main.
    - The default `maxPayload` is 100 MiB (was 16).
    - uWS no longer preallocates the whole length a frame header announces before the bytes arrive. It preallocates at most 16 MiB. This also applies to `Bun.serve` with a large `maxPayloadLength`.
24. `WebSocketServer({ perMessageDeflate })` actually negotiates permessage-deflate, and `ws.extensions` is a string. The uWS workaround for Safari 15.0-15.3 no longer applies to `ws` on node:http (npm `ws` has none). `Bun.serve` keeps it.
25. **With an `HttpsProxyAgent`, each hop is verified with its own TLS options**, as on Node. The agent's options (`rejectUnauthorized`, `ca`, ...) apply only to the connection to the proxy, and the WebSocket's options only to the target. Before, both sets were merged and used for both hops, so `new HttpsProxyAgent(url, { rejectUnauthorized: false })`, meant for a self-signed proxy, also turned off certificate checks for the **target**. Code that relied on that has to pass the option to `new WebSocket(...)` too.
    - All 42 combinations I measured on Node with the real `https-proxy-agent` (14 sets of options, for http→wss, https→ws and https→wss) now give the same result.
    - The native client gets a new option for this, `proxy: { url, headers, tls }`, also on the global `WebSocket`. With `proxy.tls` (even `{}`), the proxy hop uses only `proxy.tls` and the target only `tls`. Without it, `tls` applies to both hops as before. `serverName` and `checkServerIdentity` are never shared: `tls`'s are for the target and `proxy.tls`'s for the proxy. Proxy agents stay on the native client.
    - `proxy.tls: true` means `{}`, falsy values mean "not set", and any other non-object throws. `pfx` works in it.
    - An agent that has neither `connectOpts` nor `proxyRequestOptions` gets no `proxy.tls`, so it behaves as on main. With credentials in an agent's proxy URL, they win over a `Proxy-Authorization` header, as in `https-proxy-agent`.
    - `'error'` gets the value `checkServerIdentity` returned, as in Node. It used to get a generic TLS error. If the proxy can't be reached, `'error'` names the proxy, not the target.
26. Smaller changes:
    - A client stays CONNECTING when an `'upgrade'` listener throws, as in npm. It used to emit `'open'`.
    - A client no longer fails on a `Sec-WebSocket-Version` response header, a repeated `permessage-deflate` entry, or window bits written like `015` or `0xf`, all of which npm `ws` accepts.
    - `send()` while CONNECTING throws.
    - `ws.url` is `undefined` on server sockets.
    - `on*` / `addEventListener` are built on the emitter, as in npm: they use ws's own event classes (not `instanceof Event`), `addEventListener("upgrade")` does nothing, and the `signal` option is ignored.
    - A server socket is `instanceof WebSocket`.
    - After `'open'`, only protocol errors are `'error'` events. A reset, a broken TLS record or a failed renegotiation is just `'close'` 1006, as in npm and on main.

**Differences from npm `ws` that I kept** (veto any):
- An invalid close code throws and leaves the socket OPEN. That matches ws 8.22.0. 8.18.3, the version in `test/node_modules`, leaves it CLOSING forever.
- A header whose value is an array is sent as one line joined with ", ". npm sends one line per element. Both join `cookie` with "; ".
- `mask: false` / `generateMask` are ignored, `binaryType: "fragments"` gives one fragment, and the `Sender` / `Receiver` classes throw.
- After a native upgrade, `req.socket.pause()` doesn't stop reads (`ws.pause()` does), and `req.socket` emits no `'end'`.
- `terminate()`: on a server, `'close'` fires before the callbacks of sends made after `terminate()`. On a natively connected client, data that is already buffered is delivered during `terminate()`, not after it returns. `terminate()` in a `'message'` listener drops the rest of the packet being parsed.
- A `'message'` listener that throws doesn't stop later messages (npm's receiver stalls). `addEventListener("ping" | "pong")` works.
- A Close reason that isn't valid UTF-8 is sent with U+FFFD (npm sends the raw bytes). If that makes it longer than 123 bytes, `close()` throws `RangeError` and the socket stays OPEN.
- After the body of a non-101 response, the client closes the connection (Node keeps it for its agent).
- `send()` of a file-backed `Blob` throws (npm reads it asynchronously).
- Pings aren't answered while more than 16 MiB is buffered for the socket (npm queues a Pong for each, with no limit).
- A frame that breaks two rules at once (say RSV2 set and unmasked) is rejected with Close 1002 like in npm, but `'error'` may name the other broken rule. Frames that break one rule get npm's exact error. Released Bun emits no `'error'` and sends no Close frame for any of them.
- On a paused, natively connected client, bytes that arrive after `pause()` stay in the kernel's buffer, so `terminate()` and the close timer drop them. Node has already read them and delivers them.
- A paused native socket (client or server) doesn't read, so it doesn't see the peer's FIN until `resume()` or `terminate()`. That includes a `createWebSocketStream()` whose consumer has stalled. Node keeps reading up to 64 KiB while paused, so it sees a FIN that follows little or no data.
- `req.destroy()`, `req.abort()` or an aborted `signal` inside an `'upgrade'` listener of a natively connected client fails the handshake: `'error'`, then `'close'` 1006. Node emits `'open'`, then `'close'` 1006. I tried Node's order and took it out again: it leaked the connection when a listener threw.
- `req.setTimeout()` does nothing on a natively connected client, so an `'unexpected-response'` listener can't change or turn off `handshakeTimeout`, which stays armed until the body ends. Until the response head is complete, `handshakeTimeout` is a fixed deadline that interim responses don't restart.
- On a server, send callbacks that are still waiting when the closing handshake finishes run when the connection closes, right before `'close'`, not at the moment of the flush: with no error if the kernel took everything, else with `ERR_STREAM_DESTROYED`. `readyState` is already CLOSED there. On TLS, a drain can report success while uSockets still holds the last record.
- If a send callback throws, the ones queued behind it still run, each once. Node drops some of them, depending on how the writes were batched.
- `ws.send()` followed by `ws._socket.destroy()` in a `'message'` or `'ping'` listener of a server drops that message. `terminate()` and `close()` deliver it.
- Through a proxy, the request passed to `finishRequest` emits no `'socket'` event before `end()`. The native client connects with the final headers, so there is no socket yet.

**Not taken**:
- #35031 (WHATWG-only close codes): breaks `close(1001)` / `close(1011)`, which work today, with no gain for portable code.
- #41712 (env proxy by default): a product decision, stays open.
- #36749 (serialize async `message` handlers).
- #39255 (keep the ping deadline across sends): TCP already notices a dead peer, and it could drop a slow one that is alive.
- #44122, #20533, #37559: refactors.
- #35730, #36561: speed-ups of tests that CI skips.
- #31205: can't be reproduced.

Also not here:
- `closeOnBackpressureLimit` over TLS still takes about 12 s to close. It's an old bug, and the fix changes EOF routing in the TLS engine.
- `ws.remoteAddress` can still be `undefined` when it's first read inside `close()`. Filling it in eagerly costs a `getpeername` per connection. The docs say to read it while the socket is open; the value is kept after that.
- Throws inside `websocket.*` handlers are routed the way they are today.
- The `ws` client option `secureContext` is honored on direct connections but still ignored when connecting through a proxy (same as before this PR).

### Changes

#### Global `WebSocket` (client)

The connected client now tracks two flags (our Close frame was sent, the peer's Close frame was received) and has one exit, `finish()`. The C++ side has one `finish()`, one constructor path and one `sendFrame()`. That replaces six dispatch sites, three copies of the failure block, a 130-line switch and 14 `send` / `ping` / `pong` overloads.

Crashes, hangs, lost data:
- `send(); send(); close()` lost data. On `ws://` the socket was shut down in both directions right after the Close frame, so anything the server sent next triggered a RST that also destroyed unsent bytes. A plain echo server got 10 of 32 small messages (2 of 32 at 64 KiB) while the client reported `1000`, clean, and 50 ordinary closes caused about 50 RSTs. Now nothing is shut down after the Close frame, and the FIN is sent once both Close frames are exchanged: 32 of 32 in each case, including when the script just ends. (#45196)
- `close()` with unsent data queued and a peer that stopped reading never finished, and `terminate()` was a no-op in CLOSING, so the process hung forever. The timeout is now armed when the Close frame is queued. `terminate()` works in every state except CLOSED and reports a close code that was already received. (#36112; the timeout: no PR)
- A server's Close frame that arrived under send backpressure left `readyState` OPEN, and later `send()`s were queued behind our own Close echo. (#36112)
- A send queue whose tail landed on the end of its ring buffer never flushed the wrapped part (`bufferedAmount` stuck at 7). (#44821) Growing such a queue blocked `send()` for 2.9 / 11.4 / 43.6 s at 16 / 32 / 64 MB. `LinearFifo::realign` is now one `rotate_left`. (no PR in this set; #44929 touches the same function)
- `wss://` through a proxy: Close and Pong frames overtook queued messages (`BIN x185, CLOSE, BIN x71`), so 71 of 256 messages were lost behind a "clean" close. (#44822)
- A masked Ping, Close or continuation frame from a server was parsed with the mask key as payload, and the connection continued out of sync. Any masked frame now fails the connection. (no PR)
- `permessage-deflate; client_max_window_bits=8` was accepted, and then the first compressed message failed with `1011 "Compression not implemented yet"`. (#38632)
- `send()` / `ping()` / `pong()` of `Bun.file()` or an S3 file silently sent an empty frame. They now throw `ServerWebSocket`'s error, in every state. (#39082)
- A detached buffer hit `ASSERTION FAILED: m_ptr` on debug builds. It's now an empty payload, as in WebIDL, `ServerWebSocket`, `Blob` and `Response`. (#35029, without its TypeError)
- `new WebSocket(url, ["ÿ".repeat(357913942)])` aborted the process. (#42216, reduced to the one call site that expands its input)
- Deleted `EncodedSlice::utf16_global`, a safe fn that marked a borrowed slice as "C++ frees this". Text frames now reach C++ as an owned string. Fixes #31968. (#31982)
- `pause(); close()` kept the fd open (on TLS the server's too) until the timeout. Closing now resumes reading. `ws` is the exception: there the socket stays paused, as in npm. (#42974)

Events and state:
- Every failed connection fires `error`, then `close` 1006. Subprotocol mismatches used to give `close 1002 ... wasClean: true` and no `error`. (#45122) Failures after open do the same and send a Close frame first. Before, 22 kinds of bad frame reset the TCP connection and reported 1002 / 1007 / 1009 / 1011 without `error`. RSV1 without the extension was `1011`, "clean". An illegal close code from the server was `1002`, "clean". Corrupt deflate data was 1002 and is now 1007. (no PR)
- Errors have `error.code`, and the message includes the detail: `Expected 101 status code: 503`, `TLS handshake failed: self signed certificate`, `Proxy connection failed: 403 Forbidden`. (no PR)
- A connect failure detected synchronously (a missing unix socket) left `readyState` at 3 right after the constructor and put the whole sentence, URL included, in `close.reason`. It now looks like an asynchronous failure and reports `ENOENT`. (no PR)
- The automatic Pong is sent before the `ping` event fires and never after our Close frame. Before, `close()` in a `ping` listener on `wss://` sent an empty Pong after the Close. (no PR)
- Events run in the `AsyncLocalStorage` context of the `new WebSocket()` call, as in Node. Sockets created inside a `Bun.ModuleGraph` get their store too. The snapshot leaves out the graph's own frames, so an open socket doesn't keep its graph alive. (#33066)
- Frames that arrive with the 101 wait for `resume()` if the socket was paused in `open` or earlier (#43018), and are delivered after `open`'s microtasks (#43696). This takes one field, with no native microtask and no special cases for nested event loops.
- `isPaused` is false, and `pause()` / `resume()` return false, unless the socket is CONNECTING or OPEN. (#42978)
- After the connection closes, `bufferedAmount` keeps the bytes that were never sent, and then grows only by the payload bytes of later `send()` calls. It used to count framing and pings and skip Blobs. This is what #31760 (already closed) asked for. (#31761, as one snapshot in the single exit, with no FFI override)
- A length of 0 encoded in 16 or 64 bits is accepted, like every other non-minimal length (undici and npm `ws` accept them too). A length of 2^53 or more has its own error. (no PR)

Constructor and arguments:
- Options are read through the prototype chain, stopping at `Object.prototype`. Before, inherited keys and getters were ignored, while `Object.prototype.perMessageDeflate = false` turned compression off everywhere. (#41674)
- The documented `protocols: "chat"` threw "Value is not a sequence", and `protocol: "chat"` was dead code. (#41705) A second argument such as `null` or `5` is converted per WebIDL (was `TypeError: Type error`). `['a', 'A']` is a duplicate. (no PR)
- One invalid `proxy.headers` entry dropped all of them, including `Proxy-Authorization`. It now throws. (#37638)
- `ws://:token@host` sent no credentials, and `user:p%E9ss` sent the wrong password. (#44964) An explicit `Proxy-Authorization` header beats the proxy URL's credentials, matching `fetch` and the target (non-proxy) path. #44964 pinned the opposite. (no PR)
- The URL's credentials are kept out of `ErrorEvent.message` and the `SyntaxError`s. (#41698, using `URL::removeCredentials()`; its `fetch` half landed in #42692)
- `ping(undefined)` sent "undefined". (#39071) `ping(null)` sends no payload either, (no PR)

Opening handshake:
- A proxy's 407 was reported as "Proxy connection failed" whenever it arrived in one read, and `HTTP/1.1 200` without a reason phrase was rejected. (#43445) Any 2xx opens the tunnel, as in `fetch`. (#43511)
- A 101 with more than 128 header fields failed with "Invalid response". `server.upgrade(req, { headers })` with 125 headers triggers it. (#43243, bounded by the byte limit instead of a new constant) The byte limit gave different results for different TCP segmentation. (#41573, WebSocket half)
- `100 Continue` / `103 Early Hints` before the 101, or before the proxy's 200, are skipped. (no PR)
- Bytes read while a handshake listener spins the event loop were parsed as a new HTTP head. (#43763, the buffer simplification only)
- Header values are sent as latin-1. (#41482) `close()` / `terminate()` while CONNECTING close TCP with a FIN. (no PR)
- Found by fuzzing (no PR): the two floods in item 8, and a missing exception check where a binary message is queued because nothing listens yet.
- A response of fewer than 9 bytes that can't be HTTP (`foo\r\n`) left the client waiting for the 120 s handshake timeout. It now fails immediately with "Invalid response". A RST instead of a response reports "Connection ended: read ECONNRESET". (no PR)

#### `Bun.serve` WebSocket server

Crashes and memory safety:
- **`server.upgrade(req)` returned `true` after a streamed `Response` had started.** It wrote the handshake headers into the chunked body and then segfaulted the release build (heap-use-after-free at the next `ws.send()` under ASAN). A request can no longer be upgraded once a `Response` has been accepted for it. (no PR)
- `ws.subscribe(new String(...))` with a `toPrimitive` that closes the socket caused a heap-use-after-free at the next `publish()`, and `send` / `ping` reported bytes sent on a dead socket. All arguments are now converted first, followed by one state check. Nine methods became four bodies, and `server.publish()` shares their payload helpers. (#39385)
- Upgrading a socket that had already been ended (a `ws` `'headers'` listener that calls `req.socket.end()`) was a SEGV on debug and a hang on release. `HttpResponse::upgrade()` now rejects it up front, and `us_socket_adopt` returns NULL instead of the same pointer. The check runs again after the 101 is written, because a head longer than the cork buffer is a real TLS write, which can shut down the socket. `upgrade()` then returns `false`. (#37447, only this part; #44618 fixed its two other paths)
- `ws.send(sharedMemory, true)` while another thread writes to that memory overran libdeflate's output buffer. That code path is deleted, and #42318's two race tests pass under ASAN. (#42318 stays open for `Bun.gzipSync` and friends)
- `server.reload()` leaked a uWS context per route (12 KiB per reload with 12 routes). There's now one context per owner. A reload refreshes settings and never replaces a live context's closures. (#41680)

Lost data, hangs:
- A new TEXT / BINARY frame inside a fragmented message was appended to it, and both were delivered as one message. Fixes #33010. (#33011)
- `ws.close()` while over `backpressureLimit` lost accepted frames and the Close frame (the peer got 62 of 79, then a bare FIN). `ws.close()` inside `ws.cork()`, `drain`, or `open` after an async upgrade sent its FIN 16 s late. `end()` is now the only writer of a Close frame and owns the uncork and the FIN. (#39843, plus TLS variants of its tests)
- `terminate()`, a throwing `open`, `stop(true)` and process exit dropped data that `send()` / `publish()` had accepted in the same tick. (#43711, a third of it; no `flush` flag) A throwing `open` now always lets the 101 be sent first.
- The reply to a valid frame was lost when a later frame in the same read failed the connection (Autobahn 3.2, 4.1.4, 5.15). (falls out of #35874)
- `idleTimeout: 65536`, `maxPayloadLength: 2 ** 32 + 16` and `backpressureLimit: 2 ** 32 + 1` wrapped to 0, 16 and 1. `idleTimeout` above 960 now always throws, and the other two are clamped. Fixes #13918. (#36998; docs from #40097)
- `idleTimeout: 0` meant about 256 s (`0 - 4` in an unsigned short). `static_assert`s now guard the arithmetic. Part of #26554. (#34222)

Protocol and API:
- When the server fails a connection, it sends a Close frame with 1002 / 1007 / 1009 and the reason. That includes an invalid Close payload, which used to be echoed as an empty Close. Fixes #22723. (#35874, but the handler keeps 1006, which is what npm `ws` and browsers report)
- `stop(true)` sends 1001. Part of #25722. (#34961, the `stop(true)` half, in `TemplatedApp::close()`)
- permessage-deflate:
  - The negotiator is new and does a plain split and compare. The old tokenizer compared the *sum of the characters*, so `deflate-permessage` matched. `server_no_context_takeover` or `server_max_window_bits` is honored and echoed, invalid offers are declined, and fallback offers are tried. (#41629)
  - `compress: false` actually turns compression off, and `decompress: "16KB"` no longer sets the *compressor*. `{ compress: on, decompress: off }` keeps working. (#41637, without its throw)
  - Inbound BFINAL=1 blocks are accepted. (#33592)
  - Corrupt data closes with 1007. A message that inflates past the limit closes with 1009.
  - Each dedicated compressor and each dedicated decompressor uses 4 KB less.
- `perMessageDeflate: true` sent messages that end in a final DEFLATE block, which makes Safari close with 1002 (WebKit bug 326651). All outbound messages now end in a sync flush. Fixes #25673. I checked this on the wire; I couldn't run Safari. (no PR)
- A long `ws.close()` reason is truncated to 123 bytes on a character boundary. It used to be cut mid-character, so the client saw 1007. (#41665)
- `upgrade({ headers })` duplicated `Sec-WebSocket-Accept`, `Upgrade` and `Connection`, and removed a field from the caller's `Headers`. (#41875) `headers: {}` threw, and a subprotocol couldn't be declined. Fixes #18243. (no PR) The result depended on an unrelated `req.headers` read or `await`. (#41962) Another server's `Request` cross-wired handlers and topics. (#41811, `upgrade()` only)
- The 101 for an upgrade made after an `await` was sent in 25 writes (33 TLS records). It's now one write. (no PR)
- An Upgrade / CONNECT request without `Host` gets a 400. It used to reach `fetch` with a relative URL. (#41670)
- `upgrade(); throw`, and a rejection from a handler that upgraded after an `await`, were silently dropped. (#45000, but reported through `error()` / the `fetch` reporter instead of ending the process) `async: upgrade(); throw` is still an `unhandledRejection`.
- `websocket.error(error, ws)`, typed. Fixes #11634. (no PR in this set; #41728 touches the types)
- New: `ws.pause()`, `ws.resume()`, `ws.isPaused`. The idle timer is suspended while paused. Fixes #32483. (instead of #36749)
- `drain` only fires after a `send()` that returned `-1`. A TLS socket used to get one right after the upgrade with nothing buffered, and each `pause()` / `resume()` would have caused one. (no PR)
- `ws.cork(cb)` runs `cb` on a closed socket. `server.publish(t, d, undefined)` compresses like `publish(t, d)`. (#39253) `websocket: {}`, the first example in the docs, no longer throws "expects a message handler". (no PR)

#### `ws`

`src/js/thirdparty/ws.js` (two classes that forwarded lazily to native objects) is replaced by `ws.ts`: one `class WebSocket extends EventEmitter` ported from ws 8.22.0 (`websocket.js`, `websocket-server.js`, `event-target.js`, `stream.js`, `extension.js`, `subprotocol.js`, `validation.js`, and the negotiation half of `permessage-deflate.js`). Framing, masking, UTF-8 validation and deflate stay native, so there is no JS `Sender` / `Receiver`. All nine JS-only PRs together moved the old shim from 18 to 24 of my 212 client cases. The state machine had to live in one place. There are three transports:
1. **Client**: the native client connects. In this mode it calls one shared handler table (`this` = the `ws` object) instead of dispatching DOM events, so there are no listener objects per socket and no `MessageEvent` per frame.
2. **Server**: upstream's code builds the 101 in JS, native code writes those bytes, and uWS adopts the node:http connection into a context that has this `WebSocketServer`'s options. `_socket` is the real node:http socket, and its `'close'` is what emits the `ws` `'close'`. There are no queues and no ticks in JS.
3. **Any JS stream** (the stream transport), both roles: the native client engine reads from and writes to a JS stream. It's the slower path (numbers under Performance), and only these cases use it:
   - Server: any `Duplex`, `server.emit("connection", socket)`, http2 `allowHTTP1`, fastify's `injectWS`, a manual `setSocket()`, and a node:http connection on which frames were read before `handleUpgrade()` ran.
   - Client: a custom `agent` (SOCKS agents), `createConnection`, `lookup`, `finishRequest`. These go through the real `http.request()`, which is upstream's own path. `family`, `localAddress` and `localPort` take that path too.

Crashes, lost or corrupted data:
- **One request from any client ended the process** with an uncaught `TypeError` when the connection wasn't native: `http2.createSecureServer({ allowHTTP1: true })` (`vite --https`), `server.emit("connection", socket)` (socket.io's sticky sessions), or any `Duplex`. They now get a 101 over the stream transport. (#44460: took its native binding and `can_upgrade()`, replaced its 500; #28152's Proxy case is a test)
- `await events.once(ws, "open")` on a port that refuses connections killed the process, and `once("message")` + `on("message")` delivered each message twice. (#37972; there are no per-socket closures at all now)
- Server: a message after `ws.onmessage = null` was an uncaught `TypeError`, `addEventListener("message")` got a Buffer for text, and `onclose` got a number. Fixes #36060. (#44111)
- Server: 64 x 1 MiB arrived as 83 frames, 62 of them out of order, and `close()` behind a backlog delivered 4 of 24. The second send queue in JS is deleted. Fixes #44971, #21376. (#44520, #39802)
- `send(Buffer.from([0xff, 0x68]), { binary: false })` arrived as `ef bf bd 68`. `binary` didn't decide the frame type for ArrayBuffer and Blob. `{ fin: false }` sent three whole messages. (#44535: its native entries, plus `fin` and `compress`)
- `send()` after `close()` acknowledged a dropped message with `cb(null)`. (#39840) Callbacks now mean "written": they wait if the peer isn't reading, and they fail when the socket dies. (no PR)
- `finishRequest` with a late `req.end()` sent two handshakes, and `'open'` never fired. A request that was never ended connected anyway. (#44123, with no deferred native socket)
- `maxPayload` was ignored in both directions: a limit of 100 let 101 bytes through, the default rejected 20 MiB, and socket.io's `maxHttpBufferSize` did nothing. It's now a native limit per `WebSocketServer`, checked at the frame header. Fixes #8261. (#36650) Client `maxPayload` works too. (no PR)
- A custom `agent` was silently dropped, so a SOCKS proxy the user asked for was bypassed by a direct connection. The agent is now used. The tests run three kinds of SOCKS agent against a SOCKS5 server and check that a dead proxy isn't bypassed. Fixes #23208. (no PR)
- Frames that arrive before a *deferred* `handleUpgrade()` (after an `await`, or a `verifyClient` that answers late) were lost: `["later"]` where Node gives `["early", "later"]`. Such a connection now uses the stream transport, which starts with `head` and whatever the socket has buffered. (#43176, without its replay on `nextTick`)
- Server `close(999)`, `close(1004)` and `close(-1)` were sent on the wire. (#42011)

Features and parity:
- The closing handshake, in both roles (#39802; client: no PR):
  - After `close()` the socket is CLOSING, and messages that arrive before the peer's Close frame are still delivered.
  - `'close'` reports the peer's code (`4444 "theirs"`, was our own `4000 "mine"`), or 1006 if the peer only sends a FIN.
  - A listener added after `close()` or `terminate()` fires.
  - `terminate()` after `close()` is 1006.
  - `close()` leaves a paused socket paused.
  - The `closeTimeout` option works.
  - uWS got a mode for this: write the Close frame, keep reading, and report when the peer's Close, the FIN or `socket.destroy()` arrives. `Bun.serve` doesn't use it.
- `createWebSocketStream()` is upstream's `stream.js`, with backpressure. Fixes #4568. (#35459)
- `options.WebSocket` is honored, server sockets are `instanceof WebSocket`, and patches to `WebSocket.prototype` apply to them. Fixes #13317. (no PR)
- `ws._socket` is the node:http socket on servers. On clients it's a small object with `remoteAddress` / `remotePort` / `remoteFamily`, `ref()`, `unref()` (engine.io-client's `autoUnref`) and `destroy()`. (no PR)
- Server `pause()` / `resume()` / `isPaused`. Fixes #44453. (#44554; the tests fail against a `pause()` that only sets a flag)
- Options that were accepted but ignored now work. Client: `handshakeTimeout`, `origin`, `auth`, `followRedirects` / `maxRedirects` / `'redirect'`, `protocolVersion`, `autoPong`, `skipUTF8Validation`. Server: `autoPong`, `skipUTF8Validation`, `perMessageDeflate`. `handshakeTimeout` and `closeTimeout` are no longer capped by the native 120 s / 30 s timers, because no native timer runs for a `ws` client. `protocolVersion: 8` is actually sent, with `Sec-WebSocket-Origin`. (no PR)
- Headers that `'headers'` listeners push are included in the 101 (engine.io's cookies and `headers` event). `handleProtocols: () => false` no longer selects a protocol (graphql-ws). (no PR)
- Errors match npm's: `Unexpected server response: 403` for every kind of listener (#36375) and with or without a listener (#45125, minus its two regressions); `connect ECONNREFUSED ...` with `code`; `WS_ERR_*` for protocol errors on both sides, with Close 1002 / 1007 / 1009 on the wire. (no PR)
- `req.destroy()` / `req.abort()` work in `'unexpected-response'` and `finishRequest`. (#37973) `ping(null | false)` sends an empty frame. (#39093) Text is a Buffer for every `binaryType`, built natively. (#44255, #44553, #44112) `terminate.call(undefined)` throws. (#34873)
- `send(null)`, `send([1, 2])` and `send({})` do what npm does (empty binary, bytes, `ERR_INVALID_ARG_TYPE`). `ping` / `pong` callbacks run later, with `null`. Accessors are enumerable, and the ready-state constants are read-only. `["Foo", "foo"]` are two subprotocols (the global `WebSocket` treats them as duplicates). (no PR)
- Bun's extras carry over: `proxy`, `tls`, `HttpsProxyAgent`-style agents (connected natively), `ws+unix:`, `wss+unix:`.
- `perMessageDeflate: { serverNoContextTakeover, clientMaxWindowBits, ... }` goes into the client's offer (it was always `permessage-deflate; client_max_window_bits`), and the server's response is validated against what was offered. (no PR)
- A RST instead of a response is `read ECONNRESET` with `syscall: "read"`. A FIN is `socket hang up`. (no PR)
- The body of a non-101 response in `'unexpected-response'`: `res` now gets the whole body, decoded (Content-Length, chunked with `fetch`'s decoder, or until EOF), even when it arrives after the head. The event itself isn't delayed. Before, a chunked body arrived raw and a body that came after the head was lost, so a gateway's JSON error didn't parse. The body has backpressure: a `res` that isn't read pauses the native read. A complete body sets `res.complete`. A truncated one ends as in Node: `'aborted'`, `'error'` `ECONNRESET`, no `'end'`. (no PR)
- A 101 with no value in `Upgrade`, or without the `upgrade` option in `Connection`, is a final response with no body, as it is for node:http's parser: `'unexpected-response'`, then `'end'` on the response immediately. Without a listener it's `'error'` "Unexpected server response: 101". The global `WebSocket` fails such a 101 as before. (no PR)
- Found by fuzzing (no PR):
  - A `handleUpgrade()` that runs a few ms late (an `await`, an async `verifyClient`) lost bytes, or the peer's FIN, that node:http had read but not yet handed to JS. Lost bytes failed a valid client in about 1% of such connections. A lost FIN left the socket open for good, which a remote peer could provoke (main too). Both now take the stream transport.
  - `req.socket.remoteAddress`, `servername`, `authorized` and `authorizationError` can still be read in `'close'`, whether the upgrade came from an `'upgrade'` or a `'request'` listener.
  - A send callback that throws no longer skips `'close'`, a stream's `'finish'`, or the callbacks behind it. On the stream transport each runs exactly once, although node:net on Bun drops some write callbacks and repeats others.
  - `handshakeTimeout` also covers the body of an `'unexpected-response'`. A refused upgrade behind a pipelined request answers on the right response. `createWebSocketStream()` finishes when the closing handshake is done.
  - `tls` / `proxy.tls`: inherited, non-enumerable and getter options are honored, also next to a `pfx`, and `Object.prototype` is ignored.

#### node:http

- `socket.bytesWritten` of an `'upgrade'` / `'connect'` socket was 0. It now counts raw writes (queued ones too) and the 101 that `ws` sent, and it keeps its value after close. `bytesRead` counts the tunnel's bytes. engine.io closes every upgraded socket on its `http.Server` whose `bytesWritten` is 0 after 1 s, which is the cause of "vite / SvelteKit dev + socket.io reloads every second". Fixes #23747. (#45030: its sum and its test lines, plus corked, backpressured and closed sockets) Not counted: WebSocket frames after a native upgrade, and HTTP heads in `bytesRead`.
- A node:http server no longer registers a `Bun.serve`-style `websocket` route. The `NodeHTTPResponse` arm of `server.upgrade()`, `NodeHTTPResponse::upgrade()` and `UpgradeCTX` are deleted (375 lines).
- Pinned by tests: `socket.destroy()` and `socket.setTimeout(0)` after an upgrade; `http.Server#close()` leaves `ws` connections open.

#### undici

- Added `require("undici").ping(ws, body?)`, and `undici:websocket:ping` / `:pong` on `node:diagnostics_channel`, with a `Buffer` payload for every `binaryType`. Without them, Slack Socket Mode / Bolt reconnected forever. Fixes #37110. (#37111, with two shared listeners instead of two closures per socket, undici's order of argument checks, and `"blob"` handled)
- `require("undici").WebSocket` is now a subclass of the global one instead of the same object. Only its sockets publish to those channels. The `open` / `close` / `socket_error` channels stay silent.

#### Types and docs

- `error` / `onerror` are `ErrorEvent`. Fixes #36329. (#42673, #39069) Added `ping` / `pong` events, `binaryType: "blob"` and `Blob` payloads. (#39069) `headers` accepts `Headers` and pairs, and with lib.dom loaded, `Bun.WebSocket`'s `message` event has `.data` again. (no PR) The fixtures assert on `Bun.WebSocket`, so the list of expected lib.dom diagnostics didn't change.
- `serve.d.ts`: `error?(error, ws)`, `pause` / `resume` / `isPaused`, `message?` is optional, `cork<R>` (its `T` shadowed the data type), option ranges and what 0 means, `close()` codes, compress defaults, and four examples that called the wrong method.
- `websockets.mdx`: every example runs and type-checks as pasted (the first one threw). New "Closing connections" and "Errors" sections. One Origin check for cookie-authenticated upgrades, which allows a missing `Origin` so that non-browser clients work (#36155). Also compress defaults (#39253), backpressure (#41679, WebSocket half), `pause()` (#42976, the paragraph but not its JSDoc) and casing (#42443). The hand-copied Reference block had drifted on nine members and is replaced by links, which makes #40879 moot.
- `ws.ts` is fully typed (`tsc` on `src/js`: 0 errors, also with `noImplicitAny`). Adding the types left the generated module byte-identical.

#### Tests

- #39370 took `websocket-server.test.ts` on a debug build from 40 s with 4 timeouts to 13 s and green (the file has grown since).
- The writev fault test never reached `us_socket_write2`. (#38174)
- `websocket-pause.test.ts` streams 32 MiB instead of 256 MiB and fails immediately when a send is dropped. (#41109, the constant and about 15 lines)
- `ws-syscall-fault.test.ts` went from 7.6 s to 3.2 s on debug. (#37115, without the asserts that froze four differences from npm)
- `setSocketOptions()` of `bun:internal-for-testing` (`SO_SNDBUF` / `SO_RCVBUF`) now takes any socket Bun owns: a `ws` or global `WebSocket`, a `ServerWebSocket`, TLS sockets, node:net and node:http sockets. It also works on Windows, where it did nothing. With a 64 KiB send buffer a sender hits backpressure after about 200 KB instead of 4.2 MB, so the backpressure tests send 1 MiB instead of 12 to 64 MiB.
- No test sits through a real close timeout (uSockets sweeps every 4 s, so "1 s" is 4 to 8 s). Those were removed, and the numbers are under "Fuzzing before merge".
- Autobahn fan-out (#35421): not run here, it needs docker.
- `hostname: "127.0.0.1"` where a test connects to 127.0.0.1. (#41331, plus `websocket-proxy.test.ts`) I haven't swept the other files that have the same pattern.
- Two stale `todo`s are now real tests, and so are `ws`'s five early-frame `todo`s (#43176). The empty `perMessageDeflate` todo and an empty `skip` are removed.
- New in `test/js/first_party/ws`: the `ws-*-compat-*.test.ts` files, `ws-stream-transport.test.ts`, a rewritten `ws-upgrade-events.test.ts` (18 files) and one raw-peer helper. Every block runs twice: on the built-in, and on `node_modules/ws` loaded by path. No sleeps.

### Performance

**`bench/websocket-server`** (the chat room in this repo, 32 clients in 4 client processes as its README suggests, server on 4 reserved cores, 6 interleaved rounds, this PR at `6564a70`):

Throughput against main: `Bun.serve` pub/sub -1.1% (main against a copy of itself: +1.4%), `ws` -4.6% (copy: -4.1%). That's inside the noise, so I also counted instructions per delivered message. `Bun.serve`: 758 on main, 775 here. `ws`: 12,477 on main, 12,628 here (95% of the time is in the kernel's TCP stack).

**Receiving compressed messages in a client** (global `WebSocket`, server without context takeover, which is `Bun.serve`'s default; user-space instructions per message, this PR at `6605b96`):

| JSON message | main | this PR |
|---|---|---|
| 512 B | 35,958 | 16,375 |
| 2 KB | 57,948 | 26,022 |
| 16 KB | 224,533 | 101,941 |
| 64 KB | 718,398 | 333,248 |

The client tried libdeflate first, which can't inflate an unfinished stream, so it decoded every message, failed at the end, and zlib decoded it again. It now appends a final empty block, as uWS does on the server, and only uses libdeflate without context takeover.

**My own scripts:**

Both binaries are CI's own release builds: this PR at `d51fcce`, and `9510e74`, the main commit it's based on. 8 physical cores were reserved: 4 for the side being measured and 4 for the other end, which is always main's binary. Runs are interleaved, and each number is the median of 7 rounds. "main again" is a byte-identical copy of main's binary, so the difference between the first two columns is the noise.

`ws` client (echo server: main's `Bun.serve`):

| shape | main | main again | this PR |
|---|---|---|---|
| ping-pong 64 B, round trips/s | 43,469 | 43,838 | 46,403 |
| pipelined echo 64 B, msg/s | 261,627 | 263,397 | 299,713 |
| echo 16 KB, msg/s | 98,230 | 94,591 | 97,603 |
| compressed echo 2 KB, msg/s | 106,904 | 106,657 | 107,253 |
| idle connection, KB | 9.2 | 9.1 | 10.4 |

`ws` server (load generator: main's native `WebSocket`):

| shape | main | main again | this PR |
|---|---|---|---|
| ping-pong 64 B, round trips/s | 43,011 | 43,784 | 45,172 |
| pipelined echo 64 B, msg/s | 271,840 | 276,301 | 273,717 |
| echo 16 KB, msg/s | 81,858 | 83,697 | 82,962 |
| `send()` to 100 clients in a loop, 128 B text, deliveries/s | 345,531 | 352,509 | 381,826 |
| idle connection, KB | 7.9 | 7.7 | 7.4 |

The loop row is `for (const c of wss.clients) c.send(data, { binary: false })`, not pub/sub. It measures per-`send()` overhead. The old shim decoded the `Buffer` to a string on every such call; the new code passes the bytes and the opcode straight through.

Compressed echo on the server can't be compared, because the old shim never negotiated permessage-deflate.

The global `WebSocket` client and `Bun.serve` are level with main on the same three shapes (ping-pong, pipelined echo, echo 16 KB): every difference is under 1.6%, and main differs from its own copy by up to 2.4%.

`Bun.serve`, `ws.send(x, true)` echo with the libdeflate compress path deleted (older numbers: local release builds, a busy machine): the shared compressor is 10% to 2.5x faster than main from 64 B to 16 KB. The dedicated one is 7% slower at 64 B and 1.3x to 5x faster from 512 B to 16 KB. Bytes on the wire for 64 B to 4 KB slices of `package.json`: the shared compressor is within 4 bytes of main, and the dedicated one is 11 to 26% smaller from 512 B up.

Supporting the server role in the client engine costs a natively connected client one predictable branch per received frame and two per sent frame. The global `WebSocket` table above measures that. Every `JSWebSocket` cell grew by 16 bytes (owner and handler table). Each upgrade client is 4 KB smaller (a header array was removed).

Binary size: +64 to +128 KB per target (CI's table).

### How did you verify your code works?

**Oracles.** Node 26.3.0 (its global `WebSocket` is undici 8.3); npm `ws` 8.18.3 (the repo's test dependency), on Node and, loaded by path, on Bun; the source and tests of `ws` 8.22.0; RFC 6455, RFC 7692 and the WHATWG standard. Where Node and a spec disagree, I say so above.

**Against Node + npm `ws`** (release build):

| what | main | this PR |
|---|---|---|
| `ws`'s own test suite, 271 tests, in a sandbox without network | 94 | 234 |
| 212 client cases: one process each, raw `node:net` peer, output compared with Node's byte for byte | 18 | 179 |
| 159 server cases, same method | 67 | 150 |
| new permanent suite, built-in rows that fail (measured at 1,233 rows; it has grown since) | 800 of the 1,082 that finish | 0 |

- The remaining failures in `ws`'s own suite reach into the JS `Sender` / `Receiver` / `net.Socket` of a natively connected client (`ws._receiver`, `ws._socket.on`, `generateMask`, file-backed Blobs), which don't exist here.
- The remaining client cases differ in the wording of engine errors (V8 / JSC), ws 8.22 versus the 8.18 that produced the recorded output, the order of request headers, and the kept differences listed above.

**Each new or changed test was first run on main's release build**, and I read the failure. It has to fail for the stated reason, not by timing out. Some new tests pass on main: they pin existing behavior to guard the rewrites.

**Debug + ASAN build**, with `BUN_JSC_validateExceptionChecks=1` as on CI's ASAN lane: every file under `test/js/web/websocket`, `test/js/bun/websocket` and `test/js/first_party/{ws,undici}`, plus `node-http`, `node-https`, `node-http-connect`, `node-http-with-ws`, `node-tls-connect`, `bun-server`, `request-smuggling`, fastify-websocket and `bake/deinitialization`. 80 files and 6,001 tests, all pass.
- Wire logs from a raw peer and the client on one clock: `send` x 32 + `close` delivers 32 of 32 (main: 10, and 2 at 64 KiB); 256 of 256 through the tunnel with the Close last; Pong before Close on `wss://`.

**CI**: every platform built, and the new `ws` suite passed on macOS and Windows.

**Fuzzing before merge.** Up to seven agents, one area each, ran seeded generators in a jail on CI's release builds of this PR, main, released Bun and Node, and on debug + ASAN. After each round I fixed what they found, another agent turned each repro into a test that fails on the old binary, and the hunt ran again on the new one.

| | round 1 | round 2 | round 3 | round 4 |
|---|---|---|---|---|
| cases | over 1M | 400k | 300k | 200k |
| crashes, sanitizer reports | 0 | 0 | 1 use-after-free | 0 |
| regressions from main | 6 | 5 | 0 | 0 |
| bugs in the fixes of the round before | | 6 | 5 | 2, low |

- Proxy trust: over 350,000 combinations of options, certificates and routes against a model. Nothing opened that must be rejected, and no credential or client certificate reached the wrong peer, except a `pfx` next to inherited or hidden options (fixed). The same model flags main in 90 of 800 cases.
- The use-after-free came from one of my fixes for round 1: it ran the `ws` drain handler after uWS's close handler, when GC may already have freed the native socket. It needed a script that clears private symbols. That code is gone, and uWS again calls nothing after the close handler.
- Close timeout, measured on CI's release build on ws, wss, a unix socket and through http, https and authenticated proxies: `send(100 MB); close()` at 2 MB/s delivered 82.5 MB and closed 1006 before the fix, 100 MB and a clean 1000 after it. A peer that stops reading at once, halfway or 1 KB before the end is dropped one timeout after its last read. One that reads everything but never answers is dropped one timeout after the flush.
- Late `handleUpgrade()`: 9 of 1,250 connections lost bytes before the fix, 0 of 1,500 after it, then 0 of about 50,000 generated cases of valid traffic (the generator fails 119 of 8,000 on the build before).

**`Bun.serve` against main**, for "keeps accepting what released Bun accepts": the same 8,700 server calls, 1,492 client calls, 26 handshakes and 19 close codes on both. What only this PR refuses: an HTTP/1.1 upgrade without `Host`, a repeated `Sec-WebSocket-Key`, `idleTimeout` of 65,536 or more, and `upgrade()` with another server's request. Five more were found this way and taken out. At scale (50,000 connections, a 20-minute soak with 438,000 connections, fan-out, dead consumers, reconnect storms, `stop()` / `reload()` with 20,000 open) only item 13's memory and item 10's close code differ from main.

**Two security scans** of the whole diff found nothing above low severity except two denial-of-service bugs in `ws`. All four findings were reproduced and fixed with tests:
- Unbounded automatic Pongs (described above). The cap is on all three transports, because a peer can choose the transport by sending a frame before a deferred upgrade.
- A redirect `Location` (`/#`, `ws+unix::/x`) threw out of the native handshake callback and ended the process.
- Bun's `tls: { ca, checkServerIdentity, ... }` option was dropped whenever the client connects through `http.request()` (`finishRequest`, `lookup`, `family`, ...), so a certificate pin was lost (1.4.3 enforces it).
- `tls`, `proxy` and `agent` could be set through a polluted `Object.prototype` (1.4.3 has one such case too).

**A review of the whole branch** found 15 issues, fixed with tests. The worst: a use-after-free of the server when `handleUpgrade()` gets a request from a server that was already freed, a stream wrapping the socket that `handleUpgrade()` bypassed, and the ignored client options `signal` and `secureContext`.

**Lints**: `cargo clippy --workspace`, `cargo check --workspace --all-targets`, `rust:check-all` (every target), `bun lint`, the three `tsc` projects, clang-format, rustfmt, prettier, `test/internal/source-lints`.

**Types**: `bun-types.test.ts` passes 22 of 22. With main's `.d.ts` and the new fixtures, 10 of 22 fail.

**Not run**:
- #44633's script on Windows. It's worth trying, since the 101 and the Close now leave in one write. Some backpressure tests are skipped on Windows, as they were in the original PRs.
- Autobahn (needs docker), Safari, WebView with a real Chrome.
- "No ping and no idle close on a `ws` server" has no automated test, because it needs 135 s. I checked it by hand, with the three options injected into main.

### The pull requests

Thanks to the outside contributors whose reports and patches are in here: @C0casio45 (#28152), @hughescr (#34222), @steipete (#44553, #44554, #45030), @davidhevn (#42673), @deepshekhardas (#40879, #42443), @ousamabenyounes (#36749), @alex-mextner (#31205); and to @1cedsoda, whose #44061 / #44165 stay open.

"ext" = from an outside contributor. taken = as it was, or nearly. reworked = same bug, different fix. superseded = falls out of a larger change here.

| PR | author | verdict | why |
|---|---|---|---|
| #20533 | Jarred-Sumner | not taken | clang already emits `bswap` / `rol`, so the assembly is identical |
| #24914 | Jarred-Sumner | obsolete | fixed by #25475; in BoringSSL the line marks every TLS socket low priority |
| #28152 | C0casio45 (ext) | obsolete | next-ws awaits before `handleUpgrade()`; #39642 fixed that. Proxy case kept as a test |
| #31205 | alex-mextner (ext) | not taken | its cases compile on main; I widened `headers`, which is probably what the author hit |
| #31761 | robobun | reworked | one snapshot in the single exit; counts payload bytes only |
| #31982 | robobun | taken | |
| #33011 | robobun | taken | plus BINARY inside TEXT |
| #33066 | robobun | reworked | one `dispatchEvent` override, not 20 renamed call sites |
| #33592 | robobun | taken | |
| #34222 | hughescr (ext) | reworked | the one-line fix + `static_assert`s; no test binding, no 20 s tests that pass without the fix |
| #34873 | robobun | superseded | by the port |
| #34961 | robobun | reworked | `stop(true)` half only; `stop()` stays graceful as documented |
| #35029 | robobun | reworked | `span()` kept; detached = empty, no TypeError |
| #35031 | robobun | not taken | breaks `close(1001)` / `close(1011)` |
| #35421 | robobun | taken | |
| #35459 | robobun | superseded | upstream's `stream.js`, with the backpressure the PR dropped |
| #35730 | robobun | not taken | 0.8 s on a test that CI skips |
| #35874 | robobun | reworked | handler keeps 1006; 1007 vs 1009 for inflate errors |
| #36112 | robobun | taken | plus the missing timeout |
| #36155 | robobun | reworked | one check and a short warning; a missing `Origin` is allowed |
| #36375 | robobun | superseded | upstream's `abortHandshake`, every kind of listener |
| #36561 | robobun | not taken | same as #35730 |
| #36650 | robobun | reworked | native limit per `WebSocketServer`; `'close'` is 1006, not 1009 |
| #36749 | ousamabenyounes (ext) | not taken | frames from one read still overlap; deadlocks request / response over one socket. `ws.pause()` instead |
| #36808 | robobun | obsolete | replaced by #37275 |
| #36998 | robobun | taken | |
| #37111 | robobun | reworked | shared listeners, `"blob"`, undici's check order |
| #37115 | robobun | reworked | `describe.concurrent` only |
| #37439 | robobun | stays open | WebSocket half landed in #40624; the rest is `fetch` |
| #37447 | robobun | reworked | the early rejection only; #44618 fixed two of its three paths |
| #37463 | robobun | obsolete | landed in #43557; test kept |
| #37559 | robobun | not taken | refactor of code that #40018 rewrote |
| #37638 | robobun | taken | |
| #37972 | cirospaciari | superseded | one handler table, so no listeners to install twice |
| #37973 | cirospaciari | superseded | one request class; npm's rows |
| #38174 | robobun | taken | |
| #38177 | robobun | obsolete | fixed by #40566 |
| #38632 | robobun | taken | |
| #39069 | robobun | reworked | `.d.ts` taken; fixture on `Bun.WebSocket` |
| #39071 | robobun | taken | plus `null` |
| #39082 | robobun | reworked | the server's error class, one FFI call |
| #39093 | robobun | superseded | upstream's line |
| #39253 | robobun | taken | plus `publish(t, d, undefined)` |
| #39255 | robobun | not taken | a `send()` cancels the ping deadline, as released |
| #39370 | robobun | taken | |
| #39385 | Jarred-Sumner | taken | ported by hand; `sendBinary`, `close`, ping / pong too |
| #39608 | robobun | stays open | not WebSocket; its two `ErrorEvent` lines are here |
| #39802 | robobun | reworked | no JS queue, no tick; native "wait for the peer's Close" |
| #39840 | robobun | superseded | upstream's lines |
| #39843 | robobun | taken | plus TLS tests |
| #40097 | robobun | superseded | by #36998; docs lines kept |
| #40879 | deepshekhardas (ext) | obsolete | the block it fixes is deleted |
| #41109 | robobun | reworked | the constant + 15 lines |
| #41331 | robobun | taken | |
| #41482 | robobun | reworked | Rust only, smaller |
| #41573 | robobun | reworked | WebSocket half; `fetch` half dropped |
| #41629 | robobun | reworked | new negotiator, fallback offers, no x-webkit |
| #41637 | robobun | reworked | no throw for a config that works today |
| #41665 | robobun | taken | |
| #41670 | robobun | taken | |
| #41674 | robobun | taken | |
| #41679 | robobun | reworked | three sentences; `routing.mdx` half not taken |
| #41680 | robobun | reworked | never replaces a live context's closures |
| #41698 | robobun | reworked | `removeCredentials()`; `fetch` half landed in #42692 |
| #41705 | robobun | taken | inside #41674's code |
| #41712 | robobun | stays open | product decision |
| #41728 | robobun | stays open | not only WebSocket; overlaps in `websockets.mdx` and `error` |
| #41811 | robobun | reworked | `upgrade(Request)` only |
| #41875 | robobun | taken | plus `headers: {}` and declining a protocol |
| #41962 | robobun | taken | |
| #42011 | robobun | superseded | upstream's checks, both roles |
| #42216 | robobun | reworked | 5 lines, no budgets |
| #42318 | robobun | stays open | the libdeflate patch is a vendoring decision; the WebSocket path is deleted, its two tests are kept |
| #42443 | deepshekhardas (ext) | taken | |
| #42673 | davidhevn (ext) | taken | its two lines; a different fixture, because the lib.dom run fails with the PR's own |
| #42974 | robobun | reworked | `resume()` on close; the rest follows from `readyState` |
| #42976 | robobun | taken | trimmed |
| #42978 | robobun | taken | derived from `readyState` |
| #43018 | robobun | reworked | with #43696: one field instead of four |
| #43176 | robobun | reworked | these connections use the stream transport; as written, the PR would deliver `head` twice |
| #43243 | robobun | reworked | bounded by the byte limit |
| #43445 | robobun | taken | |
| #43511 | robobun | taken | |
| #43614 | robobun | stays open | goes with #43441 |
| #43696 | robobun | reworked | the deletion only; no nested-event-loop cases |
| #43711 | robobun | reworked | `terminate()` and exit flush; no flag |
| #43763 | robobun | reworked | "always buffer"; no `handshake_dispatched` |
| #44061 | 1cedsoda (ext) | stays open | RFC 8441 is a separate feature; needs a rebase |
| #44111 | robobun | superseded | upstream's `event-target.js`, both roles |
| #44112 | robobun | reworked | second native handler set, no per-event flag |
| #44122 | robobun | not taken | used its options struct and test table; no `validate()` / `dial()` split |
| #44123 | robobun | reworked | real `http.request()`; no deferred native socket |
| #44165 | 1cedsoda (ext) | stays open | built on #44061 |
| #44255 | robobun | superseded | native Buffer for text |
| #44460 | robobun | reworked | binding + `can_upgrade()`; 101 instead of 500 |
| #44520 | robobun | reworked | no backpressure limit, JS queue deleted |
| #44535 | robobun | reworked | native entries + `fin`, `compress`; no lint |
| #44553 | steipete (ext) | superseded | same result, built natively; test kept |
| #44554 | steipete (ext) | reworked | public `ServerWebSocket.pause()`; tests that fail against a no-op `pause()`; no 120 s kill |
| #44821 | robobun | taken | plus `realign` |
| #44822 | robobun | reworked | three small changes instead of five mechanisms |
| #44964 | robobun | taken | explicit `Proxy-Authorization` wins |
| #45000 | robobun | reworked | reports the error and keeps serving |
| #45030 | steipete (ext) | reworked | its sum and tests; plus cork, backpressure, close, `bytesRead`, the 101 |
| #45122 | robobun | taken | extended to failures after open, and 1006 |
| #45125 | robobun | superseded | falls out of `emit("error")`; two regressions avoided |
| #45196 | robobun | reworked | moves the `close` event; no second parser |

Issues that can also be closed:
- #22870: the client option works; the reporter set it on the server.
- #18869: the code in its trace was rewritten by #39642 / #39435, and it has no s…
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

2 participants