Repository navigation
Unmask WebSocket server frames in blocks instead of byte by byte - #47555
Conversation
The three loops that unmask client frames XORed one byte at a time. On
x64 clang compiles them to code that runs at 2-3 GB/s, which was close to
half of the CPU time a server spent per received kilobyte.
Unmask in 64 byte blocks with a 32/16/8/4 tail. A block is loaded whole
before it is stored, so it stays correct where the destination trails the
source by 4 to 14 bytes. It compiles to plain 16 byte vector loads, XORs
and stores on x64 and arm64: no intrinsics, no runtime dispatch.
unmaskAll, the special case for a read that fills the receive buffer, has
nothing left to add and is gone. A continuation now rounds its length up
to 4 rather than always going past it, so such a read still ends exactly
at the end of the buffer.
Messages per second received by Bun.serve on one core, release build,
from a client that sends prebuilt masked frames (x64, Cascade Lake):
32 B 4.76M -> 4.86M +2%
256 B 2.65M -> 3.34M +26%
1 KB 0.96M -> 1.41M +46%
16 KB 81.8K -> 134K +64%
1 MB 1515 -> 2114 +40%
Not measured on arm64, where clang already vectorized some of the old
loops.
The behavior does not change, so the new tests pass without this change
too. They fail when the new code is broken on purpose: unmasking 8 bytes
past a payload, skipping the first fragment, or skipping a continuation
(every split case fails, so each piece does arrive as its own read).
|
Updated 10:22 AM PT - Oct 31st, 2026
❌ @claude, your commit 8e215e5 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 47555That installs a local version of the PR into your bun-47555 --bun |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Word-based unmasking packages/bun-uws/src/WebSocketProtocol.h |
The protocol adds helpers that process 64-byte blocks and remaining four-byte-aligned data. In-place unmasking and partial server continuation data use this path. The previous byte-wise and receive-buffer-sized helpers are removed. |
Masked frame regression tests test/js/web/websocket/raw-peer.ts, test/js/bun/websocket/websocket-server.test.ts |
The raw-peer helper accepts an optional masking key and uses it for large payloads when supplied. Echo tests cover lengths from 0–299, selected boundary lengths, payloads larger than 512 KiB, and frames split across reads. |
Possibly related issues
oven-sh/bun#46968— This issue also concerns WebSocket server throughput. This change updates masked-frame processing, but the issue may involve event-loop behavior and is not shown to be resolved here.
Suggested reviewers: alii, jarred-sumner
Merge Risk: 
Minimal · up to 8e215
-
The block-based unmasking preserves the inspected frame-processing contracts. No actionable merge-blocking issue was found; merge after normal checks pass.
Pre-merge checks: 
4
-
Full details: Description check
-
Explanation
The description includes both required sections, explains the implementation and performance impact, and provides detailed verification results, test coverage, limitations, and known environment constraints.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
There was a problem hiding this comment.
I reviewed this PR and did not find any bugs. Because it rewrites the unmasking hot path of the vendored uWS frame parser with an overlapping-copy invariant and lands on a performance claim, a human look at the approach and the numbers would still be worthwhile.
What was reviewed:
- Traced all three callers of the new
unmaskWords: bothunmaskImprecisesites and the exact-tailunmaskInplaceread and write the same byte ranges as the old byte loops, anddst <= srcholds at each (src-4, frame start vs. src+header, in place). - Checked the overlap claim: every block is fully loaded into a local before any store, and a store at dst..dst+63 with dst <= src cannot reach the next block at src+64.
- Checked the partial-continuation change to
(length + 3) & ~3u: it differs from the old((length >> 2) + 1) * 4only whenlength % 4 == 0, by 4 bytes past the received data that are never delivered or spilled;rotateMaskis already skipped in that case. - Confirmed the dropped
libusockets.hinclude is still reached transitively by all four includers viaWebSocketContextData.h, and that the tests exercise concatenated frames, a payload over the receive buffer, and rotated-key continuation reads with non-zero keys.
Extended reasoning...
The diff replaces the byte-wise client-frame unmasking in packages/bun-uws/src/WebSocketProtocol.h (used by Bun.serve's WebSocket server) with a memcpy-based 64-byte block scheme plus a 32/16/8/4 tail, deletes the unmaskAll/UnrolledXor fast path, and adds tests in test/js/bun/websocket/websocket-server.test.ts and a key option in test/js/web/websocket/raw-peer.ts. It touches a parser over peer-controlled network bytes, but the read and write extents per call are identical to the base code and no new bounds depend on untrusted input, so no new security-sensitive surface is opened. I deferred rather than approved because this is a performance rewrite of a vendored hot path whose correctness rests on an overlap invariant enforced only by a debug ASSERT and on perf numbers I cannot reproduce here; no CODEOWNERS entry covers the changed paths and no third-party objections exist in the timeline.
|
Build #136119 is red for What fails: one of the 267 IR tests in that file. Line 220 is a Why it is not this PR's:
Fix: I found none, on main or in an open PR, so there is nothing to bring into this branch. The fix belongs in a separate PR that updates the expectation or the compiler, not in this one. I can't re-run BuildKite jobs from here. I'll go through the rest of the build when it finishes. Generated by Claude Code |
What does this PR do?
Makes
Bun.serveunmask incoming WebSocket frames up to 10x faster on x64, which is 26-64% more messages per second for payloads of 256 bytes and up.The three loops in
WebSocketProtocol.hthat unmask client frames (unmaskImprecisefor a whole frame and for the first part of a split one,unmaskInplacefor the rest of a split one) XORed one byte at a time. On x64, clang compiles them to code that runs at 2-5 GB/s. At 16 KB messages, this change removes about 40% of the CPU time the server spends per received kilobyte.They now go through one function,
unmaskWords, which works in 64 byte blocks with a 32/16/8/4 byte tail. A block is loaded whole before it is stored, so it stays correct where the destination trails the source by 4, 6, 8 or 14 bytes. It is plain C++ (memcpyto and from a local array): no intrinsics and no runtime dispatch. With our flags it compiles to four 16 byte loads, four XORs and four stores per block on x64 (movdqu/pxor) and on arm64 (ldp q/eor/stp q).Every hunk:
unmaskBlock,unmaskTail,unmaskWords: the new code.unmaskImpreciseandunmaskInplacekeep their signatures, andunmaskImprecisestill handles exactly((length >> 2) + 1) * 4bytes, so it still cannot reach the header of the next frame in the same read.unmaskAllandUnrolledXorare deleted. They were the fast path for a continuation read that fills the 512 KiB receive buffer. The general path is now as fast (numbers below), so the branch has nothing left to add.(length + 3) & ~3u) instead of always going 1-4 bytes past it. Without this, droppingunmaskAllwould have made a full read write 4 bytes into the buffer's padding, which it did not do before.#include <libusockets.h>goes, becauseLIBUS_RECV_BUFFER_LENGTHwas its only use in this header.wtf/Assertions.hcomes in for theASSERTon the preconditions ofunmaskWords.raw-peer.ts:encodeFrametakes an optional maskingkey. It uses the all-zero key above 64 KiB, so tests built on it never exercised the XOR for frames with a 14 byte header.End to end
Messages per second received by
Bun.serve. Two release builds from the same tree that differ only in this header. The server is pinned to one core and itsmessagehandler counts bytes. A client in another process, on another core, writes prebuilt masked frames to a raw socket. Best of 5 by server CPU time, averaged over two interleaved before/after rounds. Intel Cascade Lake, Linux.64 KB improved by at least 60%, but the two "after" rounds disagreed (32.3K and 47.0K, from 20.0K), so it is not in the table.
Parser only
The real
WebSocketProtocol<true, Impl>::consume()of each version, with a stubImpl, fed back-to-back masked frames in reads of at most 512 KiB. The buffer is refilled from a pristine copy before every read and the cost of that copy is subtracted. clang 23,-O3 -march=nehalem, AMD Zen 4. Both versions report the same payload checksums.unmaskAllbefore)Nothing measured got slower. The last row is the deleted fast path against what replaces it.
Source of the parser benchmark
Put each version of
WebSocketProtocol.hinold/andnew/.stub/holds an emptymimalloc.h, awtf/Assertions.hthat definesASSERT(x)as((void)0), and awtf/SIMDUTF.hwith asimdutf::validate_utf8that returnstrue.What this does not show
unmaskInplaceand one of the two uses ofunmaskImpreciseon arm64, so expect less than on x64.highway_fill_with_skip_mask, which the WebSocket client uses, would pick AVX2 or AVX-512 at run time. On the Cascade Lake machine the new code runs at about 40 GB/s (25 ns/KB) and the server now spends about 455 ns of CPU per KB at 16 KB messages, so unmasking is around 5% of that. Wider vectors would buy little, and the indirect call would cost small frames.How did you verify your code works?
The behavior does not change, so the new tests also pass without this change. They cannot fail on the unfixed build. What shows that they test something is that they fail when the new code is broken on purpose (below).
New tests, in
test/js/bun/websocket/websocket-server.test.tsunderunmasks client frames. A raw client sends frames masked with a non-zero key and compares what an echo server returns:straceshows the server's socket getting a read of exactly 524288 bytes in the middle of it, in 3 runs of 3 on Linux, which is the caseunmaskAllused to handleMutations, each run against the first and third groups on a debug + ASAN build. They were run before two later edits, the
(length + 3) & ~3urounding and theASSERT, and not repeated after them:unmaskImprecisehandles 8 bytes past the payload instead of at most 4That every split fails, not only some, also shows that the pieces really do reach the server as separate reads. The test relies on a ping/pong round trip on a second connection for that. If the pieces were ever merged into one read (I have only checked Linux), the test would still pass and would only cover less.
Also:
-O0and-O3, with each buffer ending exactly where the code is allowed to stop.WebSocketclient in another process: 4,140 messages with random masks, from 0 bytes to 2.5 MB, including a burst of 2,000 small ones, echoed back unchanged overws://andwss://.websocket-server.test.tsand the-unmasked-frames,-rsv-frames,-upgrade-early-frames,-fail-close-codesand-permessage-deflatefiles next to it, andws-server-compat-receive.test.ts: 627 pass, 0 fail.raw-peer.ts, 28 passed (3,353 tests). That run was also before those two edits. The 29th,ws-server-compat-transports.test.ts, could not load on my machine because@fastify/websocketwas not installed.Generated by Claude Code