Repository navigation
fix(qmux): native WebSocketStream sheds datagrams only on a real send backlog - #8136
Conversation
Quest-Claim: 187a05e8c19d7485
… backlog Chromium's native WebSocketStream writable counts writes, so its desiredSize reaches 0 while any one write is in flight. The scheduler read that as backpressure and dropped every datagram written behind it. A BacklogSink now hands each write over at once and counts the bytes whose write is still pending, held under the session's send buffer size, for every transport but the ponyfill. A headless-Chromium test proves all datagrams written back to back arrive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 12 minutes. View limit details
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. |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head 5bdf37e against base 9bf34b2.
No actionable findings. BacklogSink separates an outstanding write from byte-budget congestion while keeping the scheduler's single-writer ordering and bounded admission. The ponyfill retains its existing byte-based sink, and caller-provided streams use the new accounting too. Resizing wakes blocked admission; write rejection reaches waiting operations and the session even after an earlier enqueue resolved. The added flush keeps graceful transport close behind accepted frames under the existing CLOSE_TIMEOUT. Frame encoding allocates/copies payloads, so early enqueue completion does not expose queued frames to caller buffer reuse.
The browser regression explicitly requires native WebSocketStream and checks all datagram values for both awaited and burst writes, using a later ordered stream as its receive barrier. The unit cases cover congestion, resizing, errors and close-frame completion.
Direction: keep byte-backlog accounting at the sink boundary rather than disabling datagram shedding. As documented, frames already accepted into that bounded backlog cannot be reprioritized; only scheduler-queued frames can.
Verification limits: GitHub-only static review of the diff, complete scheduler, session attachment/send/teardown callers, and frame encoding. No Chromium/Bun execution, builds, CI or throughput measurements run; the reported before/after delivery counts and validation were not independently reproduced. Rechecked open/non-draft state, full head/base and existing reviews immediately before posting.
(Written by review (OpenAI))
There was a problem hiding this comment.
ℹ️ No critical issues — one edge case inline, plus two smaller notes.
Reviewed changes
This run read the full PR at 5bdf37e against 9bf34b20: the new sink, the scheduler lifecycle changes, the session transport selection, the new browser regression, the moved Bun adapter, and the quest deletion.
BacklogSink— new byte-backlog sink for every writable except the ponyfill: each write is handed over immediately and bytes whose write has not completed are counted against a resizable high-water mark.SendSink/SendScheduler— newflush()andclosed; close now waits for accepted writes (the close frame included) to reach the socket, an accepted write failing while the loop is parked fails the session, and#failbecame idempotent.Session— selectsBacklogSinkfor native and caller-provided writables, makessendBufferSize/setSendBufferSizeeffective there, and drops the ponyfill-only doc claims.- Tests —
BacklogSinkunit tests, session fixtures lowered to a one-byte send buffer, a headless-Chromium datagram regression injust test harness, andBunSocketStreamextracted totest/qmux/bun-socket.ts. - Quest —
quest/m1/websocket-stream-datagrams.mdcompleted and deleted with its milestone entry.
Verified locally: bun test in js/qmux (241 pass) and js/net's src/connection/connect.test.ts (57 pass). The Chromium suite needs the Playwright browser, which this environment does not have, so it was not run here.
ℹ️ The new regression test is not gated on pull requests
The body records the harness placement as "(PR CI)", but just test harness only runs in .github/workflows/interop.yml, which triggers on workflow_dispatch, a daily schedule, and workflow_call from release-gate.yml — never on pull_request. A qmux regression this test catches would therefore surface on the next daily Interop run, not on the PR that introduces it.
Technical details
# Harness test gating
## Affected sites
- `test/justfile:27` — adds `qmux-datagrams.browser.ts` to `harness_browser_tests`
- `.github/workflows/interop.yml:98` — the only `just test harness` invocation
## Required outcome
- The PR body (and any later doc citing this test as a per-PR guard) must not claim
PR coverage that does not exist.
- If per-PR gating is actually intended, name the job that would run it. Otherwise
the daily Interop run matches this repo's existing browser-suite convention and
only the body needs correcting.ℹ️ The ponyfill module doc still credits the native API with byte backpressure
js/qmux/src/web-socket-stream.ts:10 still says "the native API observes the real send buffer". That is the reading this PR refutes: the native writable's desiredSize counts writes, not bytes. The new comment at session.ts:874-876 says so, so the two now disagree, and the module doc is the one a future reader is likely to trust.
Technical details
# Stale native-backpressure claim
## Affected sites
- `js/qmux/src/web-socket-stream.ts:10` — "the native API observes the real send buffer"
- `js/qmux/src/session.ts:874-876` — the new comment: the native API's `desiredSize` counts writes
## Required outcome
- The module doc must not read as "native `desiredSize` measures bytes", which is
the assumption behind #8054/#8136. Say the native API applies backpressure per
write, or scope the byte claim to the ponyfill's `desiredSize`.deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
…ction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run re-reviewed the delta since the prior pullfrog review at 5bdf37e: one fix commit plus an origin/main merge that changed none of the PR's files.
sendBufferSizenormalization — theSessionconstructor now runs the option through a sharedsendBufferSize()helper (js/qmux/src/session.ts:446) that throwsRangeErrorfor a non-finite value and otherwise floors and clamps to at least one byte;setSendBufferSizereuses the same helper, so everyBacklogSink, ponyfill writable strategy, andsetHighWaterMarkcall receives a finite mark. This resolves the unclamped constructor mark raised in the previous review.- Regression test — a new unit test (
js/qmux/src/session.test.ts:279) asserts the constructor refusesNaNandInfinityinstead of parking every write behind an invalid high-water mark.
Verified locally at 89fc91b: bun test src/session.test.ts in js/qmux (130 pass) and bunx tsc --noEmit -p tsconfig.json (clean).
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed head 89fc91b against base fbd1beb, incrementally since 5bdf37e, accounting for the merged main changes.
No new actionable findings. The sendBufferSize fix addresses the constructor-validation gap missed in my earlier review and identified by pullfrog. Construction now normalizes finite values to at least one byte before opening or adopting a transport. Both construction and resizing reject NaN and either infinity; the setter validates before modifying the existing budget or notifying either sink. This avoids the NaN stall that a Math.max clamp alone would preserve.
Direction: sharing validation at the Session boundary keeps native, adopted and ponyfill transports consistent without weakening byte-backlog shedding. The added regression covers constructor rejection for NaN and positive infinity; it does not directly exercise finite-value clamping or invalid resizing.
Verification limits: GitHub-only static inspection of the substantive incremental patch, current constructor/setter and test context, and the PR diff. No Bun/Chromium tests, builds or CI commands were run. Rechecked the open/non-draft state, full head/base and published reviews immediately before posting. COMMENT only, not approval.
(Written by review (OpenAI))

Problem
On Chromium's native
WebSocketStream, the writable uses the default one-chunk count strategy, sowriter.desiredSizeis 0 whenever any one write is in flight, however idle the socket.WritableStreamSink.wantsMore()read that as backpressure, soSendScheduler.enqueueDatagramdropped every datagram that arrived while the previous frame was being written. moq-dev/moq#8054 fixed the same thing for the ponyfill only.Measured in headless Chromium 156 (QMux session from the page to a Bun
Session.accept, 100 one-byte datagrams on an idle localhost socket):writer.writeawaitedA raw probe shows the cause:
desiredSizegoes 1, 0 (one write in flight), -1 (two), back to 1 once both complete.Approach
BacklogSink(injs/qmux/src/scheduler.ts) hands each write to the writable at once and counts bytes whose write has not completed as the send backlog. That backlog is held under the session'ssendBufferSize, the same way the ponyfill holdsbufferedAmount.ready(),write(), andwantsMore()all read that byte count, so only a real backlog past the mark sheds datagrams. The session uses it for every transport except the ponyfill, whose own strategy is already byte-based. That covers nativeWebSocketStream, a caller-passed native stream, and customWebSocketStreamLikeadapters.SendSinkgainsflush(): the scheduler counts the close frame as sent only once the socket holds it, soCLOSE_TIMEOUTstill bounds close and the native socket is not closed over a queued close frame.SendSinkgainsclosed(the writer'sclosed): a write the sink already accepted can fail while the scheduler is parked, and the scheduler now fails the session from it instead of waiting for the next write.#failis idempotent.setSendBufferSizeandsendBufferSizenow apply to nativeWebSocketStreamtoo, so their "ponyfill-only" docs are gone.Tests:
test/interop/clients/js/qmux-datagrams.browser.ts(runs injust test harness, PR CI): headless Chromium over nativeWebSocketStream, both write patterns, every datagram must arrive. It fails with the session change reverted.BacklogSinkunit tests: a write in flight is not backpressure but bytes past the mark are, a raised mark admits a parked write, a failed write failswrite/ready/flush, and close waits for the close frame's write.WebSocketStream) still counts one stuck write as a backlog.ServerWebSocketadapter moved fromtest/qmux/interop.test.tstotest/qmux/bun-socket.ts, so both tests share it.Impact
SendSink,WritableStreamSink, andBacklogSinkare internal.SessionOptions.sendBufferSizeandSession.setSendBufferSizenow also take effect on nativeWebSocketStream(docs updated).WebSocketStream, up tosendBufferSize(64 KiB default) of frames are now handed to the socket ahead of time instead of one frame. This matches the ponyfill, where the same amount sits inbufferedAmount. Frames past that stay in the scheduler and can still be reprioritized.Alternatives
openWebSocketStreamwith a byte-sized strategy. Rejected: it misses caller-passed streams, and closing would need its own flush logic outsideCLOSE_TIMEOUT.Choices made without asking
just test harnessbrowser suite (PR CI), not in nightlyjust test qmux.BacklogSinkto customWebSocketStreamLikeadapters as well as native ones. Any count-based writable has the same bug.Validation
just ci origin/main: pass.just test harness: the new test passes. One pre-existing failure:publish-ui.browser.ts> "invalid attributes and properties refuse, retain bad markup, and recover" (invalid: "30"→threw: false, recovered: false). It reproduces unchanged onorigin/main(9bf34b2) withbun test ./publish-ui.browser.ts -t "invalid attributes".bun testinjs/qmux: 241 pass.Follow-ups
publish-ui.browser.tsinvalid-attribute check failing on main (themoq-publishattribute value30is no longer refused). I found no open PR for it.Review
Codex
gpt-6.1-sol(medium),quest reviewround 1 of head 89fc91b against origin/main (e6f92a6): no findings.(Written by Claude Opus 5.5)
🤖 Generated with Claude Code