Skip to content

ws client: cap close-reason transcode buffer at 125 to remove unsafe cast - #30778

Closed
robobun wants to merge 9 commits into
mainfrom
farm/bfaf4a85/ws-close-reason-buf-overrun
Closed

robobun wants to merge 9 commits into
mainfrom
farm/bfaf4a85/ws-close-reason-buf-overrun

Conversation

@robobun

@robobun robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #30771 — WebSocket<SSL>::close in src/http_jsc/websocket_client.rs transcodes the close reason into a 128-byte stack buffer and then pointer-casts the buffer to &mut [u8; 125] before calling send_close_with_body(.., body_len = cursor.position()). When the transcoded UTF-8 lands in (125, 128], the downstream &data[..body_len] read on the 125-byte array reference panics at runtime — and the panic crosses the extern "C" boundary, aborting the process.

Repro

A UTF-16 reason of 42 × U+0800 (126 UTF-8 bytes) passes the C++ reason.length() > 123 spec check because length() is code units, not UTF-8 bytes:

const ws = new WebSocket(url);
ws.addEventListener('open', () => ws.close(1000, '\u0800'.repeat(42)));

Runtime panic:

panic: range end index 126 out of range for slice of length 125

Same failure mode via the Latin-1 arm (copy_latin1_into_utf8 can transcode 64 × 0x80 → 128 UTF-8 bytes) and the UTF-8 arm (any 126-byte pre-tagged UTF-8 slice).

Cause

Three transcoding arms in close() each write into close_reason_buf: [u8; 128]:

  • 16-bit: cursor.write_all(&to_owned_slice()) — succeeds up to 128 bytes before returning WriteZero.
  • UTF-8: cursor.write_all(str.slice()) — same.
  • Latin-1: copy_latin1_into_utf8(dst, str.slice()) — returns NoSpaceLeft only when the buffer ran out before the source, so up to 128 bytes of output slips through on a 128-byte output.

Then close_reason_buf.as_mut_ptr().cast::<[u8; 125]>() is dereferenced as &mut [u8; 125]. The reference only carries 125 bytes of provenance; send_close_with_body's &data[..body_len] with body_len > 125 panics on the bounds check (and is UB under Stacked/Tree Borrows even without the panic).

Fix

Shrink close_reason_buf to [u8; 125]. The existing overflow handling in all three arms (break 'inner → fall through to the no-reason send_close_with_body(code, None, 0)) now triggers exactly when the transcoded UTF-8 exceeds the RFC 6455 125-byte payload cap, matching the Zig original's wrote.ptr[0..125] intent without the unsafe cast. The array reference we hand to send_close_with_body now covers the full 125-byte provenance, and the cast is gone.

A debug_assert!(wrote_len <= close_reason_buf.len()) catches any regression that reintroduces the bug.

Test

test/js/web/websocket/websocket-close-fragmented.test.ts gains 'close() with reason that transcodes beyond 125 UTF-8 bytes does not crash': a raw TCP server completes the WebSocket handshake, the client calls close(1000, '\u0800'.repeat(42)), and the test waits for 'close' on the client.

Before: bun test aborts with range end index 126 out of range for slice of length 125.
After: 2 pass / 0 fail.

@robobun

robobun commented May 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Jun 2nd, 2026

❌ @robobun, your commit 8a2ff86 has 1 failures in Build #60067 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30778

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

bun-30778 --bun

@coderabbitai

coderabbitai Bot commented May 15, 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

The PR bounds close-frame reason encoding to RFC limits, removes an unsafe buffer cast, adds a regression test exercising long Unicode close reasons, and JSON-stringifies the build-time OVERLAY_CSS value.

Changes

WebSocket close frame payload safety

Layer / File(s) Summary
WebSocket close buffer and send-path fix
src/http_jsc/websocket_client.rs
Reduce close-reason buffer to 125 bytes, bound encoding cursor to 123 bytes (MAX_REASON_BYTES), and replace the unsafe [u8; 128]-to-[u8; 125] pointer cast with a safe mutable borrow when calling send_close_with_body with the correct encoded length.
Regression test for close reason encoding bounds
test/js/web/websocket/websocket-close-fragmented.test.ts
Add test that spawns a child Bun process running a raw-socket WebSocket fixture; the fixture calls close() with a crafted Unicode reason to exercise transcoding within the bounds. Parent process asserts clean exit and correct close code logging.

Build-time CSS serialization

Layer / File(s) Summary
OVERLAY_CSS JSON stringification
src/codegen/bake-codegen.ts
Build-time define for OVERLAY_CSS now wraps the CSS result with JSON.stringify() to serialize it as a string in the generated bundle.

Suggested reviewers:

  • RiskyMH
  • dylan-conway
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The OVERLAY_CSS change in bake-codegen.ts is unrelated to the WebSocket close-reason fix; this is the only out-of-scope change and was retained per the author's rationale. Remove the OVERLAY_CSS JSON.stringify change from src/codegen/bake-codegen.ts as it is unrelated to the WebSocket close-reason buffer fix and should be in a separate PR.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: reducing buffer size and removing an unsafe cast from WebSocket close-reason handling.
Description check ✅ Passed The description covers the required template sections: 'What does this PR do?' (detailed explanation with context) and 'How did you verify your code works?' (test verification details).
Linked Issues check ✅ Passed The PR fully addresses #30771 by eliminating the unsafe pointer cast, shrinking the buffer to [u8; 125], enforcing the 123-byte reason limit via MAX_REASON_BYTES, and adding debug assertions to prevent regression.

✏️ 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.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Harden 36 reachable security findings across runtime, install, parsers, http #30722 - Also fixes the WebSocket close reason buffer overflow in websocket_client.rs (finding Support macros in Bun.js SSR #36: clamp body_len to 125 and bail on overlong UTF-8 transcode), but via runtime bounds checks rather than shrinking the buffer

🤖 Generated with Claude Code

Comment thread src/http_jsc/websocket_client.rs
@robobun
robobun force-pushed the farm/bfaf4a85/ws-close-reason-buf-overrun branch from 67ad0ca to 4cb9101 Compare May 15, 2026 09:32
Comment thread test/js/web/websocket/websocket-close-fragmented.test.ts Outdated
Comment thread test/js/web/websocket/websocket-close-fragmented.test.ts Outdated

@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: 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 `@test/js/web/websocket/websocket-close-fragmented.test.ts`:
- Around line 148-160: The test currently discards child stderr ("stderr:
\"ignore\"") which drops crash diagnostics; change Bun.spawn so proc is created
with stderr: "pipe" (alongside stdout: "pipe"), await proc.stderr.text()
together with proc.stdout.text() and proc.exited, and update the assertion to
include stderr in the failure output; when asserting stderr is empty, filter out
the known ASAN startup warning coming from bunEnv before comparing so benign
ASAN messages don't fail the test.
- Around line 216-219: Replace the use of the string .repeat call in the
ws.close call: locate the ws.close(1000, "\\u0800".repeat(42)) invocation and
replace the repetitive-string construction with the repository pattern using
Buffer.alloc(...).toString() (i.e., build the repeated "\u0800" payload via
Buffer.alloc(count, fill).toString() rather than String.prototype.repeat) so the
test uses Buffer.alloc for creating the repetitive payload.
🪄 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: a9778c52-1a99-4cf3-a408-be0e12c1b2b4

📥 Commits

Reviewing files that changed from the base of the PR and between f6cd1cc and 5a4e9b4.

📒 Files selected for processing (1)
  • test/js/web/websocket/websocket-close-fragmented.test.ts

Comment on lines +148 to +160
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", CLOSE_LONG_REASON_FIXTURE],
env: bunEnv,
stdout: "pipe",
stderr: "ignore",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
// With the fix: the fixture reaches the close listener, prints
// `close:1000`, and exits cleanly. Without the fix: the child aborts
// inside `WebSocket::close` before the listener fires — stdout is
// empty and exitCode is non-zero (SIGILL from the Rust panic).
expect({ stdout: stdout.trim(), exitCode }).toEqual({
stdout: "close:1000",

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.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Pipe the child stderr instead of discarding it.

This test is guarding a crash path; with stderr: "ignore", a regression only gives you stdout/exitCode and drops the most useful crash diagnostics.

♻️ Suggested change
     await using proc = Bun.spawn({
       cmd: [bunExe(), "-e", CLOSE_LONG_REASON_FIXTURE],
       env: bunEnv,
       stdout: "pipe",
-      stderr: "ignore",
+      stderr: "pipe",
     });
-    const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
+    const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+    const cleanStderr = stderr
+      .split("\n")
+      .filter(line => !line.startsWith("WARNING: ASAN interferes"))
+      .join("\n")
+      .trim();
     // With the fix: the fixture reaches the close listener, prints
     // `close:1000`, and exits cleanly. Without the fix: the child aborts
     // inside `WebSocket::close` before the listener fires — stdout is
     // empty and exitCode is non-zero (SIGILL from the Rust panic).
-    expect({ stdout: stdout.trim(), exitCode }).toEqual({
+    expect({ stdout: stdout.trim(), stderr: cleanStderr, exitCode }).toMatchObject({
       stdout: "close:1000",
+      stderr: "",
       exitCode: 0,
     });

Based on learnings, crash-prone spawned-process tests should keep subprocess output in the failure diff, and bunEnv-based tests should filter the known ASAN startup warning before asserting stderr is empty.

🤖 Prompt for 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.

In `@test/js/web/websocket/websocket-close-fragmented.test.ts` around lines 148 -
160, The test currently discards child stderr ("stderr: \"ignore\"") which drops
crash diagnostics; change Bun.spawn so proc is created with stderr: "pipe"
(alongside stdout: "pipe"), await proc.stderr.text() together with
proc.stdout.text() and proc.exited, and update the assertion to include stderr
in the failure output; when asserting stderr is empty, filter out the known ASAN
startup warning coming from bunEnv before comparing so benign ASAN messages
don't fail the test.

Comment on lines +216 to +219
// 42 code units × 3 UTF-8 bytes = 126 bytes — one byte past the
// 125-byte close-frame payload cap. C++ spec check bounds on UTF-16
// code-unit count (42 < 123), so this reaches the native close path.
ws.close(1000, "\\u0800".repeat(42));

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace .repeat(42) with the repo’s Buffer.alloc(...).toString() pattern.

This fixture is building a repetitive string in a test, and this repo explicitly avoids .repeat() for that case.

♻️ Suggested change
-  // 42 code units × 3 UTF-8 bytes = 126 bytes — one byte past the
+  // 126 UTF-8 bytes total — one byte past the
   // 125-byte close-frame payload cap. C++ spec check bounds on UTF-16
   // code-unit count (42 < 123), so this reaches the native close path.
-  ws.close(1000, "\\u0800".repeat(42));
+  ws.close(1000, Buffer.alloc(126, "\\u0800").toString());

As per coding guidelines, "To create repetitive strings in tests, use Buffer.alloc(count, fill).toString() instead of "string".repeat(count) for better performance in debug builds."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 42 code units × 3 UTF-8 bytes = 126 bytes — one byte past the
// 125-byte close-frame payload cap. C++ spec check bounds on UTF-16
// code-unit count (42 < 123), so this reaches the native close path.
ws.close(1000, "\\u0800".repeat(42));
// 126 UTF-8 bytes total — one byte past the
// 125-byte close-frame payload cap. C++ spec check bounds on UTF-16
// code-unit count (42 < 123), so this reaches the native close path.
ws.close(1000, Buffer.alloc(126, "\\u0800").toString());
🤖 Prompt for 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.

In `@test/js/web/websocket/websocket-close-fragmented.test.ts` around lines 216 -
219, Replace the use of the string .repeat call in the ws.close call: locate the
ws.close(1000, "\\u0800".repeat(42)) invocation and replace the
repetitive-string construction with the repository pattern using
Buffer.alloc(...).toString() (i.e., build the repeated "\u0800" payload via
Buffer.alloc(count, fill).toString() rather than String.prototype.repeat) so the
test uses Buffer.alloc for creating the repetitive payload.

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

Thanks — 5a4e9b4 resolves the prettier-reflow nit; all my earlier comments are now addressed. Deferring to a human since this also bundles an unrelated bake-codegen.ts OVERLAY_CSS behavioral change and overlaps with #30722.

Extended reasoning...

Overview

The headline change is a targeted fix in src/http_jsc/websocket_client.rs: the close-reason transcode buffer is now [u8; 125] (matching send_close_with_body's Option<&mut [u8; 125]> signature), the cursor is built over a 123-byte view (MAX_REASON_BYTES) so overflow bails to the no-reason path at the RFC 6455 boundary, and the unsafe cast::<[u8; 125]>() is gone. A regression test in websocket-close-fragmented.test.ts spawns a child that calls ws.close(1000, '\\u0800'.repeat(42)) and asserts clean exit. The remaining 11 files are autofix.ci/rustfmt reflows of multi-target #[cfg(...)] / #[cfg_attr(...)] attributes (whitespace-only), a jsc_hooks.rs line-wrap, and one substantive one-liner in src/codegen/bake-codegen.ts wrapping OVERLAY_CSS in JSON.stringify(...).

Security risks

The fix removes UB (out-of-bounds slice via a narrowed array reference) and a process-aborting panic across extern "C" reachable from JS WebSocket.prototype.close. The 123-byte cap prevents emitting close frames with header-length 126/127 (the §5.2 extended-length sentinels). I don't see new attack surface introduced; the test fixture binds to 127.0.0.1:0. No auth/crypto/permissions code is touched.

Level of scrutiny

Moderate. The core change is small and well-reasoned, and three rounds of review feedback (off-by-2 on the 125 vs 123 cap, test-convention nits, prettier comment reflow) have all been incorporated. However:

  • It rewrites RFC 6455 close-frame payload bounds in a hand-rolled protocol implementation — an area where off-by-one errors have wire-visible consequences.
  • It bundles an unrelated build-system behavioral change (bake-codegen.ts now JSON.stringifys the CSS define) that isn't covered by the PR title/description.
  • robobun reports CI failures on Build #54713 (at f6cd1cc); 5a4e9b4 was pushed after that comment and CI status for it isn't reflected yet.
  • github-actions flagged #30722 as fixing the same WebSocket overflow via a different approach (runtime clamp vs buffer shrink) — a human should pick one.

Other factors

All three of my prior inline threads are resolved by the current diff (the last via 5a4e9b4). The rustfmt attribute reflows are mechanical and zero-risk. The new test isolates the crash in a subprocess so a regression won't take down the runner, and asserts on stdout + exitCode per repo conventions. Given the bundled unrelated change, the open CI status, and the duplicate-PR coordination needed, this isn't a "merge without a human glance" PR.

@robobun

robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI red, but the failures are on darwin-14-aarch64-test-bun and darwin-26-aarch64-test-bun both marked Expired — the macOS aarch64 runners timed out before being assigned. No test failure on the ASAN job (where this fix actually matters) or on any Linux/Windows lane. Retrigger used once already; diff is green on everything that ran. Needs a maintainer re-run or merge.

@robobun
robobun force-pushed the farm/bfaf4a85/ws-close-reason-buf-overrun branch from 4db0921 to 8041ffc Compare May 16, 2026 11:19
@robobun

robobun commented May 16, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main. Conflict in src/http_jsc/websocket_client.rs with #30722 ("Harden 36 reachable security findings"), which fixed the same panic (finding #36) via a different approach — runtime clamp + keep the 128-byte buffer + keep the unsafe cast::<[u8; 125]>(). Kept my version:

  • 125-byte buffer (matches the &mut [u8; 125] signature exactly → no cast needed)
  • Cursor view over 123 bytes so overflow bails at the frame-length boundary (avoiding the malformed-frame edge at 124/125 bytes — header length would be 126/127, the RFC 6455 §5.2 16/64-bit extended-length sentinels)
  • Unsafe cast is gone entirely; provenance covers the full slice

Release bun on main now includes #30679's JSON lexer fix, so the bake-codegen.ts OVERLAY_CSS wrap in this PR is no longer strictly needed but is the canonical Bun.build define shape — left in.

Comment thread src/http_jsc/websocket_client.rs Outdated
@robobun

robobun commented May 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI red again on same pattern as before: debian-13-x64-asan-test-bun shard 17/20, exit status -1 (not a test failure indicator — that's agent/infra failure code). My test is in shard 14 (confirmed by running the sharding computation locally) which passed.

Looking at recent builds, ASAN shard 17 has failed with exit -1 on multiple unrelated builds (54920, 55200, 55217) — it's a known flaky infra lane, not specific to this PR. Different agents each time (ip-172-31-18-106, ip-172-31-67-55).

Diff is green on every lane that runs to completion, including the ASAN job on shards 0-16, 18, 19. Re-roll already used. Needs a maintainer to re-run the red shard or merge.

@robobun
robobun force-pushed the farm/bfaf4a85/ws-close-reason-buf-overrun branch from 8041ffc to bbf49b2 Compare May 21, 2026 11:46
@robobun

robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main. Conflict in src/http_jsc/websocket_client.rs — main now has another variation of the overflow fix (via #30722 then re-hardened in #31129): [u8; 128] buffer + runtime if wrote_len > 123 guard + unsafe cast::<[u8; 125]>() reborrow.

Kept this PR's version: [u8; 125] buffer + Cursor::new(&mut buf[..MAX_REASON_BYTES]) where MAX_REASON_BYTES = 123, no unsafe cast. The cursor view enforces the 123-byte frame-payload cap at the write sites (WriteZero / NoSpaceLeft naturally trigger break 'inner), and send_close_with_body receives &mut close_reason_buf directly since the buffer already has the exact [u8; 125] shape its signature asks for.

Rust tests still pass: bun bd test websocket-close-fragmented.test.ts → 2 pass / 0 fail. cargo check -p bun_http_jsc clean.

@robobun

robobun commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #30771 is already fixed on main via #30722 (runtime if wrote_len > 123 { break 'inner; } guard at src/http_jsc/websocket_client.rs:1701). Main's approach keeps the 128-byte buffer + unsafe cast::<[u8; 125]>() reborrow but prevents both the panic and the malformed-frame edge via the runtime check.

This PR's contribution is now purely stylistic (buffer sized to match the &mut [u8; 125] signature exactly → unsafe cast and runtime guard both become unnecessary), which isn't observable at the JS layer. The regression test proves it: ws.close(1000, '\u0800'.repeat(42)) exits cleanly on both main and this branch.

Happy to leave open if the cleanup is wanted; otherwise please close.

Comment thread src/http_jsc/websocket_client.rs Outdated
robobun and others added 9 commits June 2, 2026 23:37
…cast

WebSocket<SSL>::close allocated a 128-byte `close_reason_buf` on the
stack, transcoded the reason into it (UTF-16→UTF-8 via `to_owned_slice`
or Latin-1→UTF-8 via `copy_latin1_into_utf8`), then pointer-cast the
buffer to `&mut [u8; 125]` and passed it to `send_close_with_body`
with `body_len = cursor.position()`. The cursor position could reach
128, and the downstream `&data[..body_len]` slice overruns the
125-byte array reference.

A 42-code-unit UTF-16 reason of U+0800 passes the C++ spec check
(`reason.length() > 123`) but transcodes to 126 UTF-8 bytes, panicking
the runtime with `range end index 126 out of range for slice of length
125`. The panic crosses the `extern "C" fn close` boundary and
aborts the process.

Shrink the transcode buffer to 125 bytes so:
- `cursor.write_all` fails with WriteZero when the transcoded UTF-8
  exceeds 125 bytes (same `break 'inner` path the Latin-1 arm already
  takes on overflow), falling through to the no-reason `close` frame.
- The fixed-size array is passed directly to `send_close_with_body`,
  eliminating the unsafe pointer cast.

Regression test: send `close(1000, '\u0800'.repeat(42))` to a raw
handshake server and wait for the close event without the runtime
panicking.

Fixes #30771
The shipped `build/release/bun` binary in the tree pre-dates #30679's
JSON lexer fix, so when it runs `bake-codegen.ts` during `bun bd` or
`bun run build:release` the old lexer aborts on the leading `*` of the
minified CSS `define` value:

    *{box-sizing:border-box;...}
    ^
    error: Unsupported syntax: Operators are not allowed in JSON

Main is otherwise unbuildable with the shipped release bun. Wrap
OVERLAY_CSS in `JSON.stringify` — matches the canonical `Bun.build`
`define` pattern and parses cleanly under every JSON lexer generation.
… frame

Follow-up to the buffer-overrun fix: the 125-byte transcode buffer let a
reason of 124 or 125 UTF-8 bytes through, and `send_close_with_body`
then wrote header-length 126/127 — the RFC 6455 §5.2 extended-length
sentinels. Control frames have no extended length encoding, so the
receiver misparses the mask key as length and rejects the frame.

Keep the array at 125 bytes (to match the `Option<&mut [u8; 125]>`
signature on the other caller) but build the cursor over a 123-byte view
so `WriteZero` / `NoSpaceLeft` bail at the correct boundary.

Also address review nits on the regression test: drop the `panicked`
stderr-substring assertion (the stdout + exitCode checks already cover
the regression in two independent ways; the substring check is exactly
the pattern CLAUDE.md warns against) and drop the per-test timeout
(covered by the runner default).
autofix.ci kept collapsing three `//` comments wedged between the test
arrow's `}` and the `30_000` timeout onto the `},` line in reverse
reading order, and after three repeated bot commits the Format job gave
up with 'refusing to patch repeated bot commits'.

Move the comment to the line above `test(...)` where prettier won't
touch it, and let prettier collapse the call back to its preferred
single-line form.
@robobun
robobun force-pushed the farm/bfaf4a85/ws-close-reason-buf-overrun branch from bbf49b2 to 8a2ff86 Compare June 2, 2026 23:41
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main again. Two things resolved in src/http_jsc/websocket_client.rs:

  1. send_close_with_body signature changed on main (websocket: report the received close code to JS, not the echoed one #31518 added a dispatch_code: Option<u16> param so the close code reported to JS can differ from the echoed one). Updated the JS-initiated close call to send_close_with_body(code, None, Some(&mut close_reason_buf), wrote_len).

  2. Fixed the stale comment (flagged by claude[bot]): main's send_close_with_body now does let body_len = body_len.min(123) internally, so the old 'header length 126/127 malforms the frame' rationale no longer holds. The 123-byte cursor cap is still worthwhile — it routes an over-long reason to the clean no-reason close rather than letting .min(123) truncate mid-UTF-8 and trip terminate(InvalidUtf8) — so the comment now states that instead.

Note: as called out earlier, the underlying panic is already fixed on main via #30722's runtime guard; this PR's remaining value is removing the unsafe cast::<[u8; 125]>() + runtime guard in favor of a correctly-sized buffer. bun bd test → 2 pass, cargo check -p bun_http_jsc clean.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Final status — requesting a maintainer close this PR (or merge as cleanup, their call).

The bug from #30771 (panic + malformed close frame on an over-long close reason) is fully fixed on main via #30722's runtime if wrote_len > 123 { break 'inner; } guard, later reinforced by #31518's let body_len = body_len.min(123) clamp inside send_close_with_body. Neither symptom is reachable on current main.

This PR's only remaining delta is stylistic: a [u8; 125] buffer + 123-byte cursor view instead of main's [u8; 128] buffer + runtime guard + unsafe cast::<[u8; 125]>(). It removes the unsafe cast, but produces no observable behavior change — which is why a regression test can't fail-without-fix / pass-with-fix. There is no user-facing difference to assert on.

No further automated iteration is productive here.

IS_ERROR_RUNTIME: String(file === "error"),
IS_BUN_DEVELOPMENT: String(!!debug),
OVERLAY_CSS: css("../runtime/bake/client/overlay.css", !!debug),
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.

🟡 nit: now that OVERLAY_CSS is wrapped in JSON.stringify(), the define value is a quoted string literal — it no longer reaches the JSON lexer's auto-quote fallback. But src/parsers/json_lexer.rs:1292 still cites "(bake-codegen.ts's OVERLAY_CSS)" as the concrete in-tree example of a raw *{...} define that motivates that fallback; a reader following that pointer will now find JSON.stringify and be confused. Since the wrap is being kept, suggest dropping the parenthetical from the json_lexer comment — the generic "raw minified CSS string starting with *{...}" description before it stays accurate.

Extended reasoning...

What the issue is

This PR changes src/codegen/bake-codegen.ts:56 from passing raw minified CSS to Bun.build's define:

OVERLAY_CSS: css("../runtime/bake/client/overlay.css", !!debug),

to passing a JSON-quoted string literal:

OVERLAY_CSS: JSON.stringify(css("../runtime/bake/client/overlay.css", !!debug)),

Meanwhile src/parsers/json_lexer.rs:1286–1294 (untouched by this PR) explains why the JSON lexer tolerates ?/*/(/) instead of erroring, and gives one concrete in-tree example:

…e.g. a Bun.build define: whose value is a raw minified CSS string starting with *{...} (bake-codegen.ts's OVERLAY_CSS). Erroring here aborts Lexer::init before parse_env_json gets a chance to auto-quote.

After this PR, OVERLAY_CSS is a quoted string literal starting with ", not raw CSS starting with *, so it parses as an ordinary JSON string and never reaches the auto-quote fallback the comment is describing. The parenthetical is now a dangling cross-reference.

Step-by-step proof

  1. Before: css(...) returns minified CSS, e.g. *{box-sizing:border-box}…. Passed verbatim as a define value, the JSON lexer sees * at position 0. Per json_lexer.rs:1286–1294 it tokenizes TAsterisk without error so parse_env_json's auto-quote retry can wrap it. OVERLAY_CSS is the named exemplar of this path.
  2. After: JSON.stringify(css(...)) returns "*{box-sizing:border-box}…" — a valid JSON string. The lexer sees " at position 0, parses a string literal, and the */?/(/) tolerance arm is never entered.
  3. A future reader auditing why json_lexer.rs special-cases */?/(/) follows the comment's pointer to bake-codegen.ts's OVERLAY_CSS, finds it wrapped in JSON.stringify, and cannot reproduce the described behavior — at best confusing, at worst leading them to conclude the tolerance arm is dead code (it isn't; third-party Bun.build users can still pass unquoted defines).

Why this is in scope

The PR author's own rebase note (2026-05-16) says the JSON.stringify wrap "is no longer strictly needed but is the canonical Bun.build define shape — left in", i.e. the change is intentional and permanent. The json_lexer comment's stale example is a direct consequence of a change this PR is making and keeping. This PR's review history also shows comment-accuracy nits are accepted (the "line 1006" rot at websocket_client.rs:1635 and the stale MAX_REASON_BYTES rationale were both flagged and fixed).

Addressing the "too trivial / scope creep" objection

One verifier noted (a) the comment says "e.g." so OVERLAY_CSS is illustrative not load-bearing, (b) it documents the historical motivating case which remains true, and (c) editing json_lexer.rs would further expand an already out-of-scope change. On (a)/(b): the parenthetical is written in the present tense as a concrete file+identifier pointer ("bake-codegen.ts's OVERLAY_CSS"), not as "originally motivated by" — and it is the only concrete example given, so a reader who checks it and finds it doesn't match has nothing else to fall back on except the generic description. On (c): agreed that the cleanest resolution would be to drop the bake-codegen change from this WebSocket PR entirely (CodeRabbit's pre-merge check says the same); but if the author is keeping it — as stated — then the cross-reference it invalidates is worth a one-word mention. Either resolution is fine; flagging so it isn't silently lost.

Impact

Zero runtime impact — pure comment accuracy. The lexer's */?/(/) tolerance remains correct and useful for third-party Bun.build defines; only the cited in-tree example is now wrong. Hence nit.

Fix

Drop the parenthetical from src/parsers/json_lexer.rs:1292:

-                // unquoted value that starts with one — e.g. a `Bun.build`
-                // `define:` whose value is a raw minified CSS string starting
-                // with `*{...}` (`bake-codegen.ts`'s `OVERLAY_CSS`). Erroring
+                // unquoted value that starts with one — e.g. a `Bun.build`
+                // `define:` whose value is a raw minified CSS string starting
+                // with `*{...}`. Erroring

(Or, alternatively, drop the bake-codegen.ts change from this PR as CodeRabbit's out-of-scope check suggests, in which case the comment stays accurate as-is.)

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as fully superseded by main.

The bug from #30771 was first fixed on main via #30722's runtime wrote_len > 123 guard, and main has since refactored the close path entirely (#33055 + the encode_close_reason extraction). Current main now has exactly the clean design this PR was working toward, done better:

  • fn encode_close_reason(reason, buf) -> Option<usize> does the transcode and enforces len <= MAX_CLOSE_REASON (= MAX_CONTROL_PAYLOAD - 2 = 123) via .then_some(len)
  • send_close_with_body(code, dispatch_code, body: &[u8]) now takes a plain slice — no fixed-size array, no unsafe cast::<[u8; 125]>()

There is no unsafe cast left to remove and no observable behavior this PR changes, so it can't carry a meaningful regression test (the gate correctly rejects it). Nothing left to contribute. Thanks to everyone who reviewed.

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: close_reason_buf cast [u8;128] to [u8;125] — wrote_len may exceed provenance

1 participant