Skip to content

http_jsc: narrow unsafe surface of WebSocketUpgradeClient::connect#30785

Closed
robobun wants to merge 1 commit into
mainfrom
farm/43aedeeb/narrow-unsafe-connect-ws-upgrade
Closed

http_jsc: narrow unsafe surface of WebSocketUpgradeClient::connect#30785
robobun wants to merge 1 commit into
mainfrom
farm/43aedeeb/narrow-unsafe-connect-ws-upgrade

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented May 15, 2026

Fixes #30777.

Problem

HTTPClient::<SSL>::connect in src/http_jsc/websocket_client/WebSocketUpgradeClient.rs was marked unsafe fn over its ~300-line body, even though the body already contained discrete unsafe {} blocks around every genuine FFI call (Headers8Bit::init, (hooks.ssl_ctx_cache_get_or_create), (hooks.default_client_ssl_ctx), Self::deref, &mut *client re-derivations, (*vm_ptr).rare_data(), etc.). Marking the outer function unsafe fn added no information and hid what was actually unsafe.

Fix

  • unsafe fn connect → safe fn connect.
  • The two pairs of (*const BunString, *const BunString, usize) parameters (target headers + proxy headers) collapse to &[BunString] slices on the inner connect.
  • websocket is now NonNull<CppWebSocket> so non-nullness is encoded in the type. connect never dereferences it (just stores on the client); the deref sites are the later callbacks (handle_connect_error, did_connect, etc.) which are already marked unsafe at the site.
  • Headers8Bit::init takes slices instead of (ptr, len) pairs, dropping its own unsafe requirement. Uses zip so a length mismatch truncates to the shorter slice in release instead of OOB-panicking; debug_assert_eq! still catches the bug loudly in debug.
  • The Bun__WebSocketHTTPClient__connect / Bun__WebSocketHTTPSClient__connect extern "C" shims keep the (ptr, len) ABI unchanged (C++ callers in WebSocket.cpp are unaffected), do the bun_core::ffi::slice conversion in a focused unsafe {} block, and turn the wire *mut CppWebSocket into a NonNull (returning null on the defensive-unreachable branch) before handing off to safe connect.

The remaining unsafe {} blocks inside the body (VM pointer reads, pre-connect &mut *client re-derivations, Self::deref on error paths, raw SSL_CTX* creation) cover the genuinely unsafe operations and are unchanged.

Also includes a small src/codegen/bake-codegen.ts fix: OVERLAY_CSS is now JSON.stringify-wrapped so the define: value is a proper JSON string — older bootstrap bun binaries reject raw *{…} CSS as "operators not allowed in JSON" before the auto-quote fallback can rescue it.

Verification

  • cargo check -p bun_http_jsc is clean.
  • bun bd builds a working debug binary.
  • Tested against the WebSocket test suites that exercise this code path — websocket-client, websocket-custom-headers, websocket-subprotocol-strict, websocket-utf16-headers, websocket-accept-header-validation, websocket-close-connecting, websocket-close-fragmented, websocket-client-short-read, websocket-blob — all green locally.

Rebase notes (latest push)

Rebased onto current main with three conflicts, all mechanical:

  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs — inside Headers8Bit::init, upstream still had the old unsafe { ffi::slice(names_ptr, len) } lines above the iteration. My refactor removed the raw-pointer params from that function's signature, so those lines were dead; dropped them. Upstream's unrelated input-validation additions in handle_data / request-parse paths (saturating_add/max_http_header_size checks from Hardening: input validation and bounds tightening across 28 subsystems (round 2) #31175, Hardening: input validation and bounds tightening across 26 subsystems #31129) are preserved intact.
  • src/runtime/cli/run_command.rs — upstream changed entry_point_load_failed's err param from owned bun_core::Error to &bun_core::Error. My branch only carried cfg-attr formatting changes (from autofix.ci) that are already on main; took upstream in full.
  • src/runtime/jsc_hooks.rs — same situation: upstream's simpler (*Fs::FileSystem::instance()).tmpdir().ok()? form vs my branch's unsafe { &mut *... } autofix.ci restyle. Took upstream.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 15, 2026

Review Change Stack

Walkthrough

This PR refactors HTTPClient::connect() from an unsafe raw-pointer API to a safe slice-based API, moving safety guarantees to the FFI boundary. It also reformats platform-detection attributes to multi-line style across multiple modules and adds WebSocket header regression tests for slice boundary handling.

Changes

HTTPClient unsafe surface narrowing

Layer / File(s) Summary
connect() signature change to safe slice-based API
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
HTTPClient::connect public signature changes from unsafe fn accepting raw pointers *const BunString plus header_count and proxy_header_count to safe fn accepting &[BunString] slices for headers and proxy headers; unsafe marker removed from function declaration.
Headers8Bit::init slice-based refactoring
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
Headers8Bit::init refactored from unsafe pointer-based initializer to safe slice-based initializer accepting (names: &[BunString], values: &[BunString]); extra_headers initialization and proxy header construction inside connect() updated to call new slice-based init signature and decide proxy header presence via slice emptiness.
C-ABI shim safe conversion to slices
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
export_http_client! macro updated to convert raw (ptr, len) BunString header arrays into &[BunString] slices using bun_core::ffi::slice(...) before calling the now-safe HTTPClient::connect signature; removes prior direct forwarding of raw pointer/count into unsafe function.

Platform detection formatting and misc refinements

Layer / File(s) Summary
Error handling and test platform detection formatting
src/crash_handler/lib.rs, src/errno/lib.rs
In crash_handler, POSIX platform guards for reset_on_posix() and report() implementation selection reformatted to multi-line. In errno test module, cfg attributes for Linux/Android/Windows/wasm test case branches reformatted to one-condition-per-line style.
Performance profiler and CLI runtime utilities formatting
src/perf/tracy.rs, src/runtime/cli/Arguments.rs
In tracy.rs, cfg(not(any(...))) guarding fallback PATHS_TO_TRY for unsupported platforms reformatted to multi-line. In Arguments.rs, cfg_attr for .rodata.startup link section placement reformatted to multi-line.
CLI run_command cold-path section placement formatting
src/runtime/cli/run_command.rs
Nine #[cfg_attr(..., unsafe(link_section = ".text.unlikely"))] attributes above cold helper functions reformatted from single-line to multi-line equivalent; affected functions include configure_run_transpiler_linker, boot_bun_shell, error/exit handlers, and exec_as_if_node paths.
Upgrade and webview platform detection formatting
src/runtime/cli/upgrade_command.rs, src/runtime/webview/ChromeProcess.rs
In upgrade_command.rs, MANUAL_UPGRADE_COMMAND fallback cfg for unknown platforms reformatted to multi-line. In ChromeProcess.rs, platform-specific guards for find_chrome ARM64 fallback and read_dev_tools_active_port default candidates reformatted to multi-line.
Process spawn platform detection formatting
src/spawn/process.rs, src/spawn_sys/spawn_process.rs
In spawn/process.rs no_orphans loop, platform condition for wait path selection and fallback branch reformatted to multi-line. In spawn_sys, cfg_attr for allow(unused_labels) on non-Linux/non-Android builds reformatted to multi-line.
Filesystem singleton access refinement
src/runtime/jsc_hooks.rs
In resolve_embedded_file_to_buf, tmpdir retrieval refactored to use explicit unsafe mutable borrow of Fs::FileSystem::instance() singleton before calling .tmpdir().ok()? with adjusted line breaks and parenthesization.

WebSocket header handling regression tests

Layer / File(s) Summary
WebSocket custom header roundtrip tests
test/js/web/websocket/websocket-custom-headers.test.ts
New regression test suite for issue #30777 with helper function openAndEchoHeaders that establishes temporary WebSocket server, collects upgrade request headers, and validates client connection. Two test cases assert required core upgrade headers (connection, upgrade, sec-websocket-key, sec-websocket-version) present when zero user headers provided, and all 12 custom X-Custom-* headers round-trip without off-by-one pairing errors when many user headers provided.

Possibly related PRs

  • oven-sh/bun#30720: Line-break and parenthesization formatting changes in src/runtime/jsc_hooks.rs at resolve_embedded_file_to_buf overlap with that PR's introduction of the same helper function.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title directly describes the main change: refactoring the WebSocketUpgradeClient::connect function to narrow its unsafe surface, which is the core focus of this changeset.
Linked Issues check ✅ Passed The PR fully addresses issue #30777 by converting the unsafe fn connect to safe, replacing (ptr, len) parameters with safe slices, updating Headers8Bit::init, and maintaining focused unsafe blocks only around actual FFI operations.
Out of Scope Changes check ✅ Passed All changes are in scope: the main refactor targets WebSocketUpgradeClient::connect as required, supporting changes to Headers8Bit and extern C shims are directly related, and formatting-only attribute changes across multiple files align with the safety-improvement objective.
Description check ✅ Passed PR description fully addresses the template by explaining the problem with HTTPClient::connect being marked unsafe fn unnecessarily, and detailing the fix applied.

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


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

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 15, 2026

Updated 11:21 AM PT - May 22nd, 2026

❌ Your commit e1a5778c has 1 failures in Build #56913 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30785

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

bun-30785 --bun

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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/WebSocketUpgradeClient.rs`:
- Around line 214-221: In connect, before calling Headers8Bit::init for the
request headers and again for proxy headers, explicitly validate that
header_names.len() == header_values.len() and proxy_header_names.len() ==
proxy_header_values.len(); if either pair mismatches, return an appropriate Err
(or propagate an error) instead of relying on debug_assert_eq! so the safe API
cannot trigger an index-out-of-bounds in Headers8Bit::init; update the checks
adjacent to the current Headers8Bit::init calls referencing the symbols
header_names, header_values, proxy_header_names, proxy_header_values and
Headers8Bit::init to enforce and handle the length invariant in release builds.
- Around line 199-205: The connect function currently accepts and stores a raw
pointer websocket: *mut CppWebSocket while being declared safe; make the API
sound by either changing the connect signature to unsafe fn connect(...) (and
document the required invariant that the caller must ensure the pointee outlives
all callbacks like did_abrupt_close, reject_unauthorized, set_protocol,
did_connect_with_tunnel, did_connect) or, as an alternative minimal fix, change
the parameter type to a NonNull<CppWebSocket> (or a lifetime-bound reference) so
the pointer’s non-nullness/lifetime is encoded in the type; update the connect
declaration and its callers accordingly and ensure any storage of the pointer
uses the stronger typed wrapper rather than a raw *mut CppWebSocket.
🪄 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: 09c71104-09a4-4fd5-b23c-989fb29564a7

📥 Commits

Reviewing files that changed from the base of the PR and between 314d044 and c2bcd62.

📒 Files selected for processing (12)
  • src/crash_handler/lib.rs
  • src/errno/lib.rs
  • src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
  • src/perf/tracy.rs
  • src/runtime/cli/Arguments.rs
  • src/runtime/cli/run_command.rs
  • src/runtime/cli/upgrade_command.rs
  • src/runtime/jsc_hooks.rs
  • src/runtime/webview/ChromeProcess.rs
  • src/spawn/process.rs
  • src/spawn_sys/spawn_process.rs
  • test/js/web/websocket/websocket-custom-headers.test.ts

Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs Outdated
Comment thread src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
Comment on lines +56 to +62
// JSON.stringify so the define: value is a proper JSON string.
// Raw CSS starts with `*{…}`, which the JSON lexer only tokenises
// as a recoverable SyntaxError in bun >= 1.3.14-canary (commit
// 314d044c0a); older bootstrap bins reject it before auto-quote
// can kick in. Quoting here keeps bake-codegen.ts forward- and
// backward-compatible with the lexer change.
OVERLAY_CSS: JSON.stringify(css("../runtime/bake/client/overlay.css", !!debug)),
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.

🟡 Now that OVERLAY_CSS is wrapped in JSON.stringify(), two existing comments that cite bake-codegen.ts as a live example of passing raw *{…} CSS to define: are stale: src/parsers/json_lexer.rs:1304 ("bake-codegen.ts's OVERLAY_CSS") and test/bundler/bun-build-api.test.ts:76 ("src/codegen/bake-codegen.ts passes verbatim as OVERLAY_CSS"). The auto-quote codepath and its test remain valid for the general case; only the bake-codegen.ts attribution should be reworded (e.g. "previously passed") or dropped.

Extended reasoning...

This PR changes src/codegen/bake-codegen.ts so that OVERLAY_CSS is now passed as JSON.stringify(css(...)) instead of the raw minified CSS string. The new in-file comment is explicit about the intent: "Quoting here keeps bake-codegen.ts forward- and backward-compatible with the lexer change" — i.e., bake-codegen.ts deliberately stops relying on the JSON lexer's auto-quote rescue path.

However, two other places in the tree still describe bake-codegen.ts as a present-tense consumer of that rescue path:

  1. src/parsers/json_lexer.rs:1302-1304 — the comment explaining why */?/(/) are tokenised rather than errored says: "e.g. a Bun.build define: whose value is a raw minified CSS string starting with *{...} (bake-codegen.ts's OVERLAY_CSS)".
  2. test/bundler/bun-build-api.test.ts:76 — the comment above the auto-quote test table says: "a raw minified CSS string starts with *{...}, which src/codegen/bake-codegen.ts passes verbatim as OVERLAY_CSS".

After this PR, both statements are factually wrong: bake-codegen.ts now passes a JSON-quoted string (e.g. "\"*{box-sizing:...}\""), which the lexer parses as a normal TStringLiteral and never reaches the TAsterisk → auto-quote path. A reader following the cross-reference from json_lexer.rs to bake-codegen.ts will find JSON.stringify(...) and conclude either that the lexer comment is wrong or that the auto-quote codepath is now dead — neither of which is true.

Step-by-step:

  • Before: define: { OVERLAY_CSS: "*{box-sizing:border-box}..." } → JSON lexer sees * at offset 0 → tokenises TAsterisk (per the json_lexer.rs:1299-1306 arm) → parse_env_json fails to parse as expression → auto-quotes the whole value. The json_lexer.rs comment correctly named this as the live example.
  • After: define: { OVERLAY_CSS: "\"*{box-sizing:border-box}...\"" } → JSON lexer sees " at offset 0 → tokenises TStringLiteral → parses cleanly. The auto-quote path is never entered for this value, so citing it as "bake-codegen.ts's OVERLAY_CSS" is now a dangling reference.

Impact: None at runtime. The auto-quote codepath in json_lexer.rs and the .each(["*{box-sizing:...}", ...]) test in bun-build-api.test.ts are still correct and still needed for the general case (any user can pass raw CSS to define:). Only the parenthetical/clause attributing the behavior to bake-codegen.ts has gone stale.

Fix: Reword both comments to drop or past-tense the bake-codegen.ts citation — e.g. in json_lexer.rs change "(bake-codegen.ts's OVERLAY_CSS)" to "(as bake-codegen.ts's OVERLAY_CSS did before it was pre-quoted)" or simply delete the parenthetical; in bun-build-api.test.ts change "passes verbatim" to "used to pass verbatim" or drop the file reference. This is a comment-only doc-sync nit, not a behavioral bug.

The whole 300+ line body was `unsafe fn` despite already having focused
`unsafe {}` blocks around each genuine FFI call. Drop the outer
`unsafe` and lift the raw-pointer header params to `&[BunString]` slices
so the function signature is safe.

- `unsafe fn connect` → `fn connect`.
- `header_names: *const BunString, header_values: *const BunString, header_count: usize`
  collapses to `header_names: &[BunString], header_values: &[BunString]`;
  same for the proxy header arrays.
- `Headers8Bit::init` takes slices instead of (ptr, len) pairs, dropping
  its own `unsafe` requirement.
- The `Bun__WebSocketHTTPS?Client__connect` extern-C shim keeps the
  (ptr, len) ABI and does the `bun_core::ffi::slice` conversion in a
  focused `unsafe {}` block before handing off to safe `connect`.
- `websocket: *mut CppWebSocket` stays as a raw pointer (no deref in
  `connect` — just stored on the client).

[autofix.ci] apply automated fixes

test(#30777): cover zero/many-header slice boundaries in WebSocket connect

Lock in the two ends of the refactored slice parameter: zero user
headers hits the `ffi::slice(ptr, 0)` path in the extern-C shim, and
many user headers drives `Headers8Bit::init`'s `chunks_exact(2)`
name/value interleave. Behavioral equivalence is the guarantee — the
refactor must not lose a header or pair them off by one.

codegen(bake): quote OVERLAY_CSS define value so it parses as JSON

The raw CSS handed to `define: { OVERLAY_CSS: … }` starts with `*{…}`,
which older bootstrap bun builds reject as 'Operators are not allowed
in JSON' before the auto-quote fallback can turn it into a string
literal. The lexer was taught to recover from that in 314d044, but
the release bun driving bake-codegen.ts in `bun bd` doesn't always
have that fix yet — the bootstrap is a regular failure point.

Wrapping the CSS in `JSON.stringify` makes the define value a proper
JSON string, which both old and new lexers accept. Matches how every
other `define:` value in the block is already stringified (`side`,
`IS_ERROR_RUNTIME`, `IS_BUN_DEVELOPMENT`).

http_jsc: harden WebSocketUpgradeClient::connect against bad slice lengths and null pointer

- Headers8Bit::init now iterates via `zip`, so unequal-length parallel
  slices truncate to the shorter side in release instead of indexing
  past the end. The `debug_assert_eq!` still fires loudly in debug —
  mismatched lengths remain a programmer bug the extern-C shim can't
  produce, but the safe API no longer panics if they ever leak through.
- connect() now takes `NonNull<CppWebSocket>` instead of `*mut
  CppWebSocket`. The C++ caller in WebSocket.cpp always passes `this`
  (live object), so the non-null invariant is encoded in the type; the
  extern-C shim guards the nullable wire pointer before dispatch.
  Storing is still `Option<*mut …>.as_ptr()` — the field type stays
  the same to keep this change scoped to the connect signature.
@robobun robobun force-pushed the farm/43aedeeb/narrow-unsafe-connect-ws-upgrade branch from 3b7b0d0 to e1a5778 Compare May 22, 2026 18:11
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 22, 2026

Closing: this is a pure behavior-preserving refactor (narrowing the unsafe fn surface of WebSocketUpgradeClient::connect) and the mechanical test gate requires a test that fails before the fix and passes after — which is structurally impossible for a change that has no JS-observable behavior difference.

The two things my diff changes that a test could distinguish are both unreachable from user code:

  1. The NonNull::new(websocket) guard in the extern-C shim. C++ always passes reinterpret_cast<CppWebSocket*>(this) (never null), so there is no JS-reachable path that triggers a null pointer at this boundary.
  2. Headers8Bit::init's bounds behavior on mismatched slice lengths. The extern-C shim derives both slices from a single header_count by construction, so they cannot be unequal without a C++ bug.

Every other change is source-level: unsafe fn → safe fn, raw-pointer params → slice params, focused unsafe {} blocks in the shim. The WebSocket protocol behavior is identical before and after.

If a maintainer wants the refactor, the branch is at https://github.com/oven-sh/bun/tree/farm/43aedeeb/narrow-unsafe-connect-ws-upgrade — the code and tests are clean, CI was green on every real lane (only darwin-14-aarch64 expired on infra). The branch also carries an incidental JSON.stringify(OVERLAY_CSS) fix in src/codegen/bake-codegen.ts that avoids relying on the JSON lexer auto-quote recovery (unrelated to the refactor but it unblocks bun bd bootstrap on older release bins — worth cherry-picking even without this PR).

Ref #30777.

@robobun robobun closed this May 22, 2026
Comment on lines +407 to +411
const ws = new WebSocket(`ws://127.0.0.1:${server.port}/`, opts);
ws.onopen = () => resolveOpen();
ws.onerror = e => rejectOpen((e as any).error ?? new Error((e as any).message ?? "ws error"));
const [, headers] = await Promise.all([opened, headersSeen]);
ws.close();
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.

🟡 The new openAndEchoHeaders helper creates a WebSocket without pushing it to the module-level clients[] array, unlike every other test in this file. If Promise.all([opened, headersSeen]) rejects via ws.onerror, ws.close() is skipped and afterEach won't terminate the socket — add clients.push(ws); right after construction to match the file's established cleanup pattern (test/CLAUDE.md: "Track resources (servers, clients) in arrays for cleanup in afterEach()").

Extended reasoning...

What this is

The file declares module-level clients: WebSocket[] / servers: Subprocess[] arrays (lines 7-8) plus beforeEach(cleanUp) / afterEach(cleanUp) hooks (lines 21-22) that iterate clients and call .terminate?.() on each entry. Because these hooks are registered at module scope, they apply to every describe block in the file — including the new "WebSocket connect() header slice boundaries (#30777)" block. All nine pre-existing tests in this file follow the same pattern: construct the WebSocket, then immediately clients.push(ws) (lines 60, 97, 137, 166, 196, 233, 279, 309, 358). The new openAndEchoHeaders helper at line 407 constructs ws but never pushes it.

Code path

  1. openAndEchoHeaders is called.
  2. const ws = new WebSocket(...) runs at line 407 — ws is not added to clients[].
  3. Promise.all([opened, headersSeen]) is awaited at line 410.
  4. If ws.onerror fires (e.g. the upgrade fails for any reason), rejectOpen(...) rejects opened, so Promise.all rejects and the await throws.
  5. Because the throw happens before line 411, ws.close() is never reached.
  6. afterEach(cleanUp) runs, but clients is empty, so it doesn't terminate ws.

Why this is only a nit

The refutation is right that practical leakage is bounded: await using server = Bun.serve(...) guarantees the server is disposed when the helper unwinds (even on rejection), and tearing down the Bun.serve instance closes the peer side of the connection, which in turn tears down the client ws. An onerror'd WebSocket is also already in a failed state with nothing meaningful left to terminate. So this is not a resource leak in practice.

It is, however, an inconsistency with both (a) the explicit pattern every other test in this file follows, and (b) the documented test guideline in test/CLAUDE.md:247: "Track resources (servers, clients) in arrays for cleanup in afterEach()". The refutation argues that the clients[] array exists specifically because the other tests use a subprocess server with no using semantics — that's true for why the array was originally needed, but it doesn't make it wrong to also use it here. The module-scope afterEach already runs for these new tests regardless; pushing to clients[] is a one-line, zero-cost belt-and-suspenders that keeps the file uniform and makes the cleanup path independent of whether server disposal happens to close the peer.

Step-by-step example

  • Suppose a future regression in HTTPClient::connect causes the upgrade to fail with an error event before open fires.
  • ws.onerrorrejectOpen(err)await Promise.all(...) throws → helper unwinds.
  • await using disposes server. The client ws is left to be closed by the server teardown rather than by the test's own cleanup hook.
  • With clients.push(ws), afterEach would also call ws.terminate?.() — same outcome, but via the file's documented cleanup contract rather than as a side effect of server disposal.

Fix

const ws = new WebSocket(`ws://127.0.0.1:${server.port}/`, opts);
clients.push(ws);

One line, brings the new tests in line with the rest of the file and with test/CLAUDE.md.

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.

unsafe fn connect() has 200+ line body — narrow the unsafe surface

1 participant