Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/codegen/bake-codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ async function run() {
side: JSON.stringify(side),
IS_ERROR_RUNTIME: String(file === "error"),
IS_BUN_DEVELOPMENT: String(!!debug),
OVERLAY_CSS: css("../runtime/bake/client/overlay.css", !!debug),
// 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)),
Comment on lines +56 to +62
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.

},
minify: {
syntax: !debug,
Expand Down
137 changes: 81 additions & 56 deletions src/http_jsc/websocket_client/WebSocketUpgradeClient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,24 +198,36 @@

/// On error, this returns null.
/// Returning null signals to the parent function that the connection failed.
///
/// The signature is safe: `websocket` is an opaque back-reference this
/// function stores on the client — `connect` never dereferences it.
/// Taking `NonNull` (instead of the raw `*mut` the extern-C shim
/// receives) encodes the non-nullness invariant in the type; the C++
/// caller in WebSocket.cpp always passes `this` cast from a live
/// object. The remaining FFI pointers have been lifted to
/// `&[BunString]` slices by the extern-C shim below. The focused
/// `unsafe {}` blocks inside the body cover the genuinely unsafe
/// operations (reads through the live VM pointer, pre-connect
/// `&mut *client` derivations, `Self::deref` on the error paths,
/// raw `SSL_CTX*` creation). Later callbacks that dereference the
/// stored pointer (`handle_connect_error`, `did_connect`, etc.) still
/// carry their own SAFETY comments at the deref sites.
#[allow(clippy::too_many_arguments)]
pub unsafe fn connect(
pub fn connect(
global: &JSGlobalObject,
websocket: *mut CppWebSocket,
websocket: core::ptr::NonNull<CppWebSocket>,
host: &BunString,
port: u16,
pathname: &BunString,
client_protocol: &BunString,
header_names: *const BunString,
header_values: *const BunString,
header_count: usize,
header_names: &[BunString],
header_values: &[BunString],
// Proxy parameters
proxy_host: Option<&BunString>,
proxy_port: u16,
proxy_authorization: Option<&BunString>,
proxy_header_names: *const BunString,
proxy_header_values: *const BunString,
proxy_header_count: usize,
proxy_header_names: &[BunString],
proxy_header_values: &[BunString],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// TLS options (full SSLConfig for complete TLS customization)
ssl_config: Option<Box<SSLConfig>>,
// Whether the target URL is wss:// (separate from ssl template parameter)
Expand Down Expand Up @@ -244,8 +256,7 @@

// Headers8Bit::init only returns AllocError; handle OOM as a crash per
// the OOM contract instead of masking it as a connection failure.
// SAFETY: header_names/header_values point to header_count live BunStrings per extern-C contract.
let extra_headers = unsafe { Headers8Bit::init(header_names, header_values, header_count) };
let extra_headers = Headers8Bit::init(header_names, header_values);

let proxy_host_slice: Option<Utf8Slice> = proxy_host.map(|ph| ph.to_utf8());
let target_authorization_slice: Option<Utf8Slice> =
Expand Down Expand Up @@ -293,12 +304,9 @@
// Parse proxy headers (temporary, freed after building CONNECT request)
// Headers8Bit::init / to_headers only return AllocError; OOM should
// crash, not silently become a connection failure.
// SAFETY: proxy_header_names/values point to proxy_header_count live BunStrings per extern-C contract.
let proxy_extra_headers = unsafe {
Headers8Bit::init(proxy_header_names, proxy_header_values, proxy_header_count)
};
let proxy_extra_headers = Headers8Bit::init(proxy_header_names, proxy_header_values);

let proxy_hdrs: Option<Headers> = if proxy_header_count > 0 {
let proxy_hdrs: Option<Headers> = if !proxy_header_names.is_empty() {
Some(proxy_extra_headers.to_headers())
} else {
None
Expand Down Expand Up @@ -340,7 +348,7 @@
let client: *mut Self = bun_core::heap::into_raw(Box::new(HTTPClient::<SSL> {
ref_count: Cell::new(1),
tcp: Socket::<SSL>::detached(),
outgoing_websocket: Some(websocket),
outgoing_websocket: Some(websocket.as_ptr()),
input_body_buf,
to_send_len: 0,
headers_buf: [picohttp::Header::ZERO; 128],
Expand Down Expand Up @@ -1863,25 +1871,31 @@
}

impl<'a> Headers8Bit<'a> {
/// # Safety
/// `names_ptr` and `values_ptr` must each be null or point to `len` valid
/// `BunString`s alive for `'a`.
unsafe fn init(names_ptr: *const BunString, values_ptr: *const BunString, len: usize) -> Self {
if len == 0 {
/// Decode parallel name/value `BunString` slices into interleaved UTF-8
/// slices. Callers that reach this from the C ABI should convert their
/// `(ptr, len)` pairs with `bun_core::ffi::slice` inside an `unsafe {}`
/// block before calling.
///
/// `names` and `values` are expected to be the same length — the
/// extern-C shim derives both from a single `header_count`, so a
/// mismatch is a programmer bug. The `debug_assert_eq!` catches that
/// loudly in debug; `zip` stops at the shorter slice so a mismatch in
/// release drops the trailing unpaired entries instead of panicking
/// on an OOB index.
fn init(names: &'a [BunString], values: &'a [BunString]) -> Self {
debug_assert_eq!(names.len(), values.len());
let pair_count = names.len().min(values.len());
if pair_count == 0 {
return Self {
slices: Vec::new(),
_marker: core::marker::PhantomData,
};
}
// SAFETY: per fn contract.
let names_in = unsafe { bun_core::ffi::slice(names_ptr, len) };
// SAFETY: per fn contract — `values_ptr` points to `len` live `BunString`s.
let values_in = unsafe { bun_core::ffi::slice(values_ptr, len) };

let mut slices: Vec<Utf8Slice> = Vec::with_capacity(len * 2);
for i in 0..len {
slices.push(names_in[i].to_utf8());
slices.push(values_in[i].to_utf8());

let mut slices: Vec<Utf8Slice> = Vec::with_capacity(pair_count * 2);
for (name, value) in names.iter().zip(values.iter()) {
slices.push(name.to_utf8());
slices.push(value.to_utf8());
}

Self {
Expand Down Expand Up @@ -2230,32 +2244,43 @@
) -> *mut HTTPClient<$ssl> {
// SAFETY: extern-C contract — caller (WebCore::WebSocket C++)
// guarantees `header_names`/`header_values` point to
// `header_count` live `BunString`s (and likewise for the proxy
// header arrays), and that `websocket` is a live back-ref.
match unsafe {
HTTPClient::<$ssl>::connect(
global,
websocket,
host,
port,
pathname,
client_protocol,
header_names,
header_values,
header_count,
proxy_host,
proxy_port,
proxy_authorization,
proxy_header_names,
proxy_header_values,
proxy_header_count,
ssl_config,
target_is_secure,
target_authorization,
unix_socket_path,
offer_permessage_deflate,
)
} {
// `header_count` live `BunString`s (and likewise for the
// proxy header arrays). Lift the `(ptr, len)` pairs to
// `&[BunString]` here so the inner `connect` can stay
// safe. `ffi::slice` tolerates `(null, 0)`.
let header_names = unsafe { bun_core::ffi::slice(header_names, header_count) };
let header_values = unsafe { bun_core::ffi::slice(header_values, header_count) };

Check failure on line 2252 in src/http_jsc/websocket_client/WebSocketUpgradeClient.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

unsafe block missing a safety comment

Check failure on line 2252 in src/http_jsc/websocket_client/WebSocketUpgradeClient.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

unsafe block missing a safety comment
let proxy_header_names =
unsafe { bun_core::ffi::slice(proxy_header_names, proxy_header_count) };

Check failure on line 2254 in src/http_jsc/websocket_client/WebSocketUpgradeClient.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

unsafe block missing a safety comment

Check failure on line 2254 in src/http_jsc/websocket_client/WebSocketUpgradeClient.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

unsafe block missing a safety comment
let proxy_header_values =
unsafe { bun_core::ffi::slice(proxy_header_values, proxy_header_count) };

Check failure on line 2256 in src/http_jsc/websocket_client/WebSocketUpgradeClient.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

unsafe block missing a safety comment

Check failure on line 2256 in src/http_jsc/websocket_client/WebSocketUpgradeClient.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

unsafe block missing a safety comment
// `websocket` is a live back-ref to the C++ WebCore::WebSocket;
// WebSocket.cpp always passes `this`. Encode non-nullness at
// the boundary and return null on the defensive-unreachable
// branch so the inner `connect` can take a `NonNull`.
let Some(websocket) = core::ptr::NonNull::new(websocket) else {
return ptr::null_mut();
};
match HTTPClient::<$ssl>::connect(
global,
websocket,
host,
port,
pathname,
client_protocol,
header_names,
header_values,
proxy_host,
proxy_port,
proxy_authorization,
proxy_header_names,
proxy_header_values,
ssl_config,
target_is_secure,
target_authorization,
unix_socket_path,
offer_permessage_deflate,
) {
Some(p) => p,
None => ptr::null_mut(),
}
Expand Down
57 changes: 57 additions & 0 deletions test/js/web/websocket/websocket-custom-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,60 @@
ws.close();
});
});

// Regression for #30777: the connect() path was refactored to take
// `&[BunString]` slices (with the `(ptr, len)` → slice conversion lifted
// into the extern-C shim). Cover both ends of that slice parameter:
// the empty-headers case (`(ptr, 0)`) and the many-headers case
// (drives the `Headers8Bit::init` iteration).
describe("WebSocket connect() header slice boundaries (#30777)", () => {
async function openAndEchoHeaders(opts: { headers?: Record<string, string> }): Promise<Record<string, string>> {
const { promise: headersSeen, resolve: resolveHeaders } = Promise.withResolvers<Record<string, string>>();
await using server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
websocket: {
open(_ws) {},
message(_ws, _msg) {},
},
fetch(req, server) {
const headers: Record<string, string> = {};
for (const [k, v] of req.headers.entries()) headers[k.toLowerCase()] = v;
resolveHeaders(headers);
if (server.upgrade(req)) return;
return new Response("no upgrade", { status: 400 });
},
});
const { promise: opened, resolve: resolveOpen, reject: rejectOpen } = Promise.withResolvers<void>();
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();

Check warning on line 411 in test/js/web/websocket/websocket-custom-headers.test.ts

View check run for this annotation

Claude / Claude Code Review

New WebSocket tests don't track ws in clients[] for afterEach cleanup

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()").
Comment on lines +407 to +411
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.

return headers;
}

it("connects with zero user headers (ffi::slice(ptr, 0) path)", async () => {
const seen = await openAndEchoHeaders({});
// Core upgrade headers still arrive — the connect path must not skip
// them when the user-headers slice is empty.
expect(seen["connection"]?.toLowerCase()).toContain("upgrade");
expect(seen["upgrade"]?.toLowerCase()).toBe("websocket");
expect(seen["sec-websocket-key"]).toBeString();
expect(seen["sec-websocket-version"]).toBe("13");
});

it("connects with many user headers (Headers8Bit slice iteration)", async () => {
const custom: Record<string, string> = {};
for (let i = 0; i < 12; i++) custom[`X-Custom-${i}`] = `value-${i}`;
const seen = await openAndEchoHeaders({ headers: custom });
// Every custom header must round-trip: the refactor interleaves
// names/values via `chunks_exact(2)`, so a drop-one or pair-off-by-one
// bug would show up as a missing or swapped value here.
for (let i = 0; i < 12; i++) {
expect(seen[`x-custom-${i}`]).toBe(`value-${i}`);
}
expect(seen["connection"]?.toLowerCase()).toContain("upgrade");
expect(seen["upgrade"]?.toLowerCase()).toBe("websocket");
});
});
Loading