Skip to content

WebSocket client: reset inflater after Z_STREAM_END with context takeover - #34105

Merged
Jarred-Sumner merged 4 commits into
mainfrom
claude/farm/42140bc4/websocket-client-bfinal-reset
Jul 14, 2026
Merged

WebSocket client: reset inflater after Z_STREAM_END with context takeover#34105
Jarred-Sumner merged 4 commits into
mainfrom
claude/farm/42140bc4/websocket-client-bfinal-reset

Conversation

@robobun

@robobun robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

RFC 7692 §7.2.3 allows a permessage-deflate sender to end a DEFLATE stream with BFINAL=1 and begin a fresh stream for the next message. PerMessageDeflate::decompress broke out of its zlib loop on Z_STREAM_END but only reset the inflater when server_no_context_takeover was negotiated. With plain context takeover the persistent inflater stayed in the finished state, so every subsequent compressed message that reached the zlib path was silently delivered to onmessage as an empty payload, with no error and no close. The connection stayed open; the data was simply lost.

The zlib path is reached when a message's decompressed output exceeds the 128 KiB libdeflate fast-path buffer, or when a sync-flushed message (no BFINAL) makes libdeflate fall through. So one large BFINAL-terminated message poisons the connection for the rest of its lifetime.

Repro

import net from "node:net"; import crypto from "node:crypto"; import zlib from "node:zlib";
const msg1 = crypto.randomBytes(200 * 1024);
const msg2 = Buffer.from("hello after bfinal ".repeat(30));
const sync = p => zlib.deflateRawSync(p, { flush: 2, finishFlush: 2 }).subarray(0, -4);
const wire = [zlib.deflateRawSync(msg1) /* BFINAL=1 */, sync(msg2), sync(Buffer.from("third ".repeat(30)))];
const frame = p => { const h = [0xc2]; if (p.length < 126) h.push(p.length); else if (p.length < 65536) h.push(126, p.length>>8, p.length&255); else h.push(127,0,0,0,0,(p.length/2**24)&255,(p.length>>>16)&255,(p.length>>>8)&255,p.length&255); return Buffer.concat([Buffer.from(h), p]); };
const srv = net.createServer(s => { let b = Buffer.alloc(0); s.on("data", d => { b = Buffer.concat([b, d]); if (b.indexOf("\r\n\r\n") < 0) return;
  const key = b.toString("latin1").match(/^sec-websocket-key:\s*(.*)$/im)[1].trim();
  const acc = crypto.createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
  s.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + acc + "\r\nSec-WebSocket-Extensions: permessage-deflate\r\n\r\n");
  for (const w of wire) s.write(frame(w)); }); });
await new Promise(r => srv.listen(0, "127.0.0.1", r));
const ws = new WebSocket("ws://127.0.0.1:" + srv.address().port + "/"); ws.binaryType = "arraybuffer";
ws.onmessage = e => console.log("got", e.data.byteLength, "bytes");

Before: got 204800 bytes, got 0 bytes, got 0 bytes
After: got 204800 bytes, got 570 bytes, got 180 bytes
npm ws under Node: identical to "after".

Fix

decompress now resets the inflater whenever Z_STREAM_END is observed, in addition to the existing server_no_context_takeover reset. This matches what npm ws does (it closes and recreates its InflateRaw on stream end).

How did you verify your code works?

New test.each in test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts covering both values of server_no_context_takeover. The context-takeover case fails on main with msg2.len=0, msg3.len=0 and passes with the fix. All existing permessage-deflate tests continue to pass.

Server-side sibling: #33592 handles the analogous case in Bun.serve's uWS decompressor.


[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (47fe4b612)

test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts:
(pass) WebSocket client handles compressed continuation frames correctly [103.66ms]
(pass) WebSocket client doesn't compress small messages [65.96ms]
(pass) WebSocket client rejects messages exceeding size limit [69.02ms]
(pass) WebSocket client handles compression errors gracefully [56.75ms]
(pass) WebSocket client rejects decompression bombs [4207.44ms]
(pass) WebSocket client fails the connection on RSV1 set on a continuation frame [87.21ms]
(pass) WebSocket client fails the connection on RSV1 set on a continuation frame without deflate [21.07ms]
(pass) WebSocket client fails the connection on RSV1 set on a contr
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (14915540c)

test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts:
(pass) WebSocket client handles compressed continuation frames correctly [3.29ms]
(pass) WebSocket client doesn't compress small messages [0.99ms]
(pass) WebSocket client rejects messages exceeding size limit [4.52ms]
(pass) WebSocket client handles compression errors gracefully [0.82ms]
(pass) WebSocket client rejects decompression bombs [505.71ms]
(pass) WebSocket client fails the connection on RSV1 set on a continuation frame [1.26ms]
(pass) WebSocket client fails the connection on RSV1 set on a continuation frame without deflate [0.38ms]
(pass) WebSocket client fails the connection on RSV1 set on a control frame [0.28ms]
(pass) WebSocket client accepts RSV1 on the first frame of a fragmented compressed message [0.48ms]
(pass) WebSocket client resets inflater after BFINAL (server_no_context_takeover=false) [10.63ms]
(pass) WebSocket client resets inflater after BFINAL (server_no_context_takeover=true) [9.97ms]

 11 pass
 0 fail
 13 expect() calls
Ran 11 tests across 1 file. [702.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (47fe4b612)

test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts:
(pass) WebSocket client handles compressed continuation frames correctly [47.65ms]
(pass) WebSocket client doesn't compress small messages [32.88ms]
(pass) WebSocket client rejects messages exceeding size limit [58.89ms]
(pass) WebSocket client handles compression errors gracefully [33.07ms]
(pass) WebSocket client rejects decompression bombs [3161.03ms]
(pass) WebSocket client fails the connection on RSV1 set on a continuation frame [51.17ms]
(pass) WebSocket client fails the connection on RSV1 set on a continuation frame without deflate [24.06ms]
(pass) WebSocket client fails the connection on RSV1 set on a contro
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 691ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[2/21] gen JS modules (bundle-modules)
Preprocess modules (6794ms)
Bundle modules (34ms)
Postprocesss modules (129ms)
Bundle Functions (684ms)
Generate Code (102ms)

[7.76s] Bundled "src/js" for production
  1912 kb
  162 internal modules
  12 native modules
  90 internal functions across 19 files
[2/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  night
... (truncated)
diff hotspot
src/http_jsc/websocket_client/WebSocketDeflate.rs  |   8 +-
 ...websocket-permessage-deflate-edge-cases.test.ts | 109 ++++++++++++++++++++-
 2 files changed, 115 insertions(+), 2 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/http_jsc/websocket_client/WebSocketDeflate.rs             1      2      0
…bsocket/websocket-permessage-deflate-edge-cases.test.ts      2      6      0

RFC 7692 7.2.3 allows a sender to end a DEFLATE stream with BFINAL=1 and
start a fresh stream for the next message. The client's
PerMessageDeflate::decompress broke out of its zlib loop on Z_STREAM_END
but only reset the stream when server_no_context_takeover was
negotiated. With plain context takeover the persistent inflater stayed
in the finished state, so every subsequent compressed message that
reached the zlib path (decompressed size over 128KiB, or sync-flushed
input that libdeflate rejects) was delivered to onmessage as an empty
payload with no error or close.

Reset the inflater whenever Z_STREAM_END is observed, matching npm ws.
@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:56 PM PT - Jul 13th, 2026

@robobun, your commit 47fe4b6 has 2 failures in Build #72650 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34105

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

bun-34105 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Websocket client receives repeated messages when working with big payloads #21376 - WebSocket client receives repeated/corrupted messages with big payloads — the size-dependent corruption is consistent with large messages exceeding the 128 KiB libdeflate fast-path and hitting the zlib path where Z_STREAM_END was not resetting the inflater

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #21376

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The WebSocket permessage-deflate decompressor now detects StreamEnd and resets its inflater after completed DEFLATE messages. A raw TCP regression test covers three compressed frames with server context takeover enabled and disabled.

WebSocket Deflate Stream Reset

Layer / File(s) Summary
Track stream completion and reset inflater
src/http_jsc/websocket_client/WebSocketDeflate.rs
PerMessageDeflate::decompress records StreamEnd and resets the inflater after completed messages or when server context takeover is disabled.
Test completed-stream message handling
test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts
A raw WebSocket handshake sends three compressed binary frames and verifies their decompressed payloads with and without server context takeover.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main fix: resetting the WebSocket inflater after Z_STREAM_END with context takeover.
Description check ✅ Passed The description includes the required PR purpose and verification section, plus clear repro and fix details, so it satisfies the template.

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

@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Re #21376: that issue's symptom is the same message being repeated indefinitely (identical content delivered N times), which points at a frame buffer/offset bug in the receive path. This PR only changes the inflater state after Z_STREAM_END, and the failure mode it fixes is later compressed messages arriving as empty payloads. I ran the #21376 repro against this branch and it doesn't change the outcome, so I'm not linking it.

The cargo clippy failure is in src/runtime/napi/napi_body.rs:2811 (undocumented_unsafe_blocks), last touched by #34067; this diff doesn't touch that file.

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

LGTM — minimal, well-reasoned fix with a targeted regression test.

What was reviewed:

  • Verified InflateDecoder::reset() wraps inflateReset (src/zlib/lib.rs:1229), which is the correct call after Z_STREAM_END — no window to preserve once the sender set BFINAL=1.
  • Checked the change is purely additive to the existing server_no_context_takeover reset condition; the libdeflate fast-path early-return is untouched.
  • Test wires onerror/onclose to reject, cleans up in finally, uses port: 0, and asserts byte-exact content across both server_no_context_takeover variants.
Extended reasoning...

Overview

This PR touches two files: a 3-line native change in src/http_jsc/websocket_client/WebSocketDeflate.rs (add a saw_stream_end flag, set it on zlib::ReturnCode::StreamEnd, OR it into the existing inflater-reset condition) and a ~100-line test.each regression test in test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts. The bug: after a BFINAL=1 message reaches the zlib path under context takeover, the persistent inflater stays in the finished state and every subsequent compressed message is delivered as an empty payload. RFC 7692 §7.2.3 explicitly permits a sender to end a stream with BFINAL and start a fresh one; npm ws handles this by recreating its InflateRaw on stream end.

Security risks

None. This is internal decompression state management on the client receive path. No new allocation sizing, no user-input validation changes, no auth/crypto surface. The existing MAX_DECOMPRESSED_SIZE bomb guard is unchanged.

Level of scrutiny

Low-to-medium. The native change is a 3-line additive condition on an existing reset call. I confirmed InflateDecoder::reset() calls zlib's inflateReset (src/zlib/lib.rs:1229-1231), which is exactly the right primitive — it discards the finished-stream state without reallocating. Because BFINAL=1 explicitly terminates the DEFLATE stream, there is no LZ77 window to carry over, so resetting under context takeover loses nothing. The change cannot regress the pre-existing behavior: the only new effect is resetting an inflater that was previously stuck returning 0 bytes forever.

Other factors

  • The test is well-constructed per repo conventions: deterministic LCG bytes (not randomBytes) to force the >128KiB zlib path, test.each over both server_no_context_takeover values, failure events wired to reject the awaited promise, resources released in finally, byte-exact .equals() assertions. The PR description states the context-takeover case fails on main with msg2.len=0, msg3.len=0.
  • No CODEOWNERS entry covers src/http_jsc/websocket_client/.
  • The unrelated clippy failure noted in the thread is from #34067 and does not touch this diff.
  • The server-side sibling was handled separately in #33592 per the description.

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

LGTM — the runtime change is a single boolean OR'd into the existing reset() condition, directly implementing RFC 7692 §7.2.3, with a fails-before/passes-after regression test covering both server_no_context_takeover values. The inline nit about WEBSOCKET_GUID is cosmetic and non-blocking.

Checked that the libdeflate fast-path early-return is unaffected (it never touches the persistent zlib stream, so saw_stream_end is correctly irrelevant there), and that the error-return paths (TooLarge/InflateFailed) don't need the reset since the connection is torn down. The pre-existing server_no_context_takeover reset path is preserved unchanged.

Extended reasoning...

Overview

The PR touches two files: a 3-line effective change in src/http_jsc/websocket_client/WebSocketDeflate.rs (add a saw_stream_end bool, set it on Z_STREAM_END, OR it into the existing decompress_stream.reset() condition) and a ~105-line test.each regression test in test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts. The runtime change extends when an existing reset() call fires — it introduces no new API, no new state, and no new control-flow branches beyond the flag set.

Security risks

None identified. The change resets a zlib inflater more often, not less; it cannot cause the decompressor to accept data it previously rejected. The decompression-bomb guard (MAX_DECOMPRESSED_SIZE) and error paths are untouched. The test's raw TCP server binds 127.0.0.1:0 and is closed in finally.

Level of scrutiny

Moderate — this is protocol state-machine code in the WebSocket client, but the change is effectively one predicate widened on a call that already existed. The RFC citation (§7.2.3) is correct: after a BFINAL block the sender begins a fresh DEFLATE stream, so the receiver must reinitialize its context regardless of context-takeover negotiation. The PR description shows npm ws does the same (recreates InflateRaw on stream end), and the robobun evidence block confirms the new test fails on main (msg2.len=0, msg3.len=0) and passes on the PR under both ASAN-debug and release builds.

Other factors

  • No CODEOWNERS cover these paths.
  • The only finding from the bug-hunting pass is a cosmetic nit (inline GUID literal vs. the module-scope WEBSOCKET_GUID constant); the same file already inlines it elsewhere, so this is non-blocking.
  • I traced the libdeflate fast-path: it returns early before the zlib loop and never touches decompress_stream, so saw_stream_end staying false there is correct — the persistent zlib inflater's state is only relevant when the zlib path is actually taken.
  • Error returns inside the loop (TooLarge, InflateFailed) skip the reset, but those paths close the connection with 1009/1002, so inflater state is moot.
  • The test wires onerror/onclose to reject the awaited promise, uses port: 0, cleans up in try/finally, and asserts exact byte lengths + content equality across both parameter values — it meets the harness conventions in CLAUDE.md.
  • The unrelated cargo clippy failure noted in the thread is in napi_body.rs from a different PR and not caused by this diff.

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

LGTM — the earlier WEBSOCKET_GUID nit is addressed, and the remaining inline note is a pre-existing sibling case (npm ws truncates identically), not something this PR introduces or needs to block on.

Extended reasoning...

Overview

Two files: a 3-line logic change in src/http_jsc/websocket_client/WebSocketDeflate.rs (track Z_STREAM_END in the zlib decompress loop and include it in the post-loop reset() condition) plus a 4-line explanatory comment, and a ~105-line test.each in test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts that hand-drives a raw TCP WebSocket handshake to send a BFINAL-terminated frame followed by two sync-flushed frames, covering both server_no_context_takeover values.

Security risks

None. The change only adds a case in which the persistent inflater is reset. Resetting is strictly safe — worst case it discards a sliding window the sender already abandoned by emitting BFINAL. No new input is parsed, no bounds change, and the MAX_DECOMPRESSED_SIZE bomb guard is untouched.

Level of scrutiny

Low-to-medium. The Rust change is a textbook zlib pattern (an inflater that returned Z_STREAM_END is terminal and must be inflateReset before reuse), and it mirrors what npm ws does on the same event. The condition is purely additive to the existing server_no_context_takeover reset, so no existing path loses its reset. The robobun gate confirms the new test fails on main (ASAN debug) and passes with the fix.

Other factors

  • My prior review's only comment (reuse WEBSOCKET_GUID) was applied in 47fe4b6 and the thread is resolved.
  • The one inline finding this run is explicitly pre-existing and non-blocking: intra-message BFINAL (RFC 7692 §7.2.3.5) still truncates because the loop breaks on StreamEnd. That behavior predates this PR, npm ws under Node behaves identically, and no known server emits it — reasonable to leave for a follow-up rather than widen scope here.
  • Test quality is good: port: 0, onerror/onclose wired to reject, cleanup in finally, deterministic PRNG payload instead of randomBytes, and both negotiation variants covered.
  • The unrelated clippy failure the author noted is in napi_body.rs from #34067, not this diff.

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

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the new websocket-permessage-deflate-edge-cases.test.ts cases pass on every lane in builds #72613 / #72644 / #72650. Remaining red is unrelated to this diff:

  • cargo clippy in src/runtime/napi/napi_body.rs:2811 (main breakage from napi: keep threadsafe functions alive after their env is torn down #34067)
  • test/cli/run/no-orphans.test.ts timeout on darwin-14-x64 (perl orphan-reaping; already tracked separately)
  • test/js/web/fetch/fetch.stream.test.ts "invalid utf8 with deflate_with_headers" on darwin-14-x64, marked flaky; passes locally under bun bd test on this branch. The HTTP fetch decompressor (src/http/Decompressor.rs) is a different code path from the WebSocket client deflate touched here.
  • The rest are known flakes: install/registry tests, test-repl-close.js, napi.test.ts, bun-jsc.test.ts profiler, net-mongodb-pattern-leak.test.ts, zlib/leak.test.ts RSS threshold.

Ready for review.

@Jarred-Sumner
Jarred-Sumner merged commit d23d693 into main Jul 14, 2026
76 of 79 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/farm/42140bc4/websocket-client-bfinal-reset branch July 14, 2026 09:12
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.

2 participants