fix(remote): gate ws sends with an order-preserving backpressure queue#8073
fix(remote): gate ws sends with an order-preserving backpressure queue#8073Jinwoo-H wants to merge 1 commit into
Conversation
Two remote-terminal ws paths wrote to the socket with no backpressure handling, so a fast producer over a slow/congested link ballooned ws.bufferedAmount and main-process RSS without bound: - Server text/JSON reply path (e2ee-channel encryptedReply): had NO bufferedAmount gate (only the binary path did). A legacy client without the terminalBinaryStream capability streams terminal output as JSON, and that path has no seq/resync — so silently dropping frames there would recreate the corruption bug. Instead we now hold replies in order and drain on recovery. - Client binary send path (remote-runtime-client sendBinary): carries keystrokes and must never drop. Same held-and-drained treatment. Adds a shared, generic createWsOutboundBackpressureQueue: while bufferedAmount is over a soft cap (8 MiB) it parks frames in FIFO order and drains as the socket clears — no drop, no reorder. Only when a hard byte cap (64 MiB) is exceeded (link effectively dead) does it fire onOverflow, which closes the socket so the client reconnects and replays a fresh authoritative snapshot instead of growing memory unbounded. The existing binary drop+resync path (#8059) is untouched. Repro-first: deterministic queue unit tests (injected bufferedAmount + timer) and an e2ee text-path repro that fails on main (replies shoved onto a congested socket) and passes after the fix. Co-authored-by: Orca <help@stably.ai>
📝 WalkthroughWalkthroughAdds a reusable WebSocket outbound backpressure queue with FIFO buffering, soft and hard limits, polling-based draining, overflow handling, and disposal. E2EE text replies and remote runtime binary frames now use the queue instead of sending directly. Overflow triggers channel or subscription errors. Tests cover immediate sends, queued recovery, ordering, overflow, unwritable sockets, and E2EE reply behavior. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/shared/ws-outbound-backpressure-queue.test.ts (1)
109-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for the not-writable + under-cap enqueue path.
The suite tests unwritable mid-park (drain path) but not
enqueuewhen the socket is unwritable whilebufferedAmountis under the soft cap. That combination hits the fast-path guard flagged inws-outbound-backpressure-queue.ts(Line 118). A test asserting nothing is sent directly in that state would lock in the fix.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 6fbe86f2-9d62-455a-8d51-910b6856118e
📒 Files selected for processing (5)
src/main/runtime/rpc/e2ee-channel-text-backpressure.test.tssrc/main/runtime/rpc/e2ee-channel.tssrc/shared/remote-runtime-client.tssrc/shared/ws-outbound-backpressure-queue.test.tssrc/shared/ws-outbound-backpressure-queue.ts
Summary
Two remote-terminal websocket send paths wrote to the socket with no backpressure handling, so a fast producer over a slow/congested link (e.g. a build log or
yesover a slow SSH/relay tunnel) balloonedws.bufferedAmountand main-process RSS without bound:e2ee-channel.tsencryptedReply): had nobufferedAmountgate — only the binary path did (encryptedBinaryReply). A legacy client without theterminalBinaryStreamcapability streams terminal output as JSON viaterminal.subscribe, and that path has no seq/resync (unlike the binary multiplex path). Silently dropping frames there would recreate the corruption bug fixed in fix(remote): resync remote terminal on dropped output frames #8059, on the legacy path — so this must never drop.remote-runtime-client.tssendBinary): carries keystrokes and must never drop. It also had nobufferedAmountguard, piling encrypted input frames into the main-process ws buffer without cap.Fix
Adds a shared, generic
createWsOutboundBackpressureQueue(src/shared/ws-outbound-backpressure-queue.ts):bufferedAmountis over a soft cap (8 MiB) it parks frames in FIFO order and drains as the socket clears — no drop, no reorder.onOverflow, which tears the connection down so the client reconnects and replays a fresh authoritative snapshot — bounding RSS instead of growing without limit or losing data mid-stream.Bufferframes).Wired in two places:
e2ee-channel.ts:encryptedReplyenqueues through the queue; overflow →onError(1013, ...)(Try Again Later) closes the channel. Disposed indestroy().remote-runtime-client.ts:sendBinaryenqueues through the queue; overflow →fail(...)closes the socket (renderer resubscribes). Disposed incleanupSocketListeners.The existing binary drop+resync path (#8059,
encryptedBinaryReply) is deliberately untouched — no double-gating, no deadlock.Behavior under congestion (per path)
terminal.subscribe)ws.send→ bufferedAmount/RSS balloonws.send→ bufferedAmount/RSS balloonScreenshots
No visual change.
Testing
pnpm typecheck(green)pnpm testfor affected suites:src/main/runtime/rpc/**,remote-runtime-client,runtime-environments(IPC), plus the new queue + text-path repro — 571 + 7 tests pass.ws-outbound-backpressure-queue.test.ts(injected bufferedAmount + timer) ande2ee-channel-text-backpressure.test.ts(fake ws with controllable bufferedAmount).Pre-fix (text-path repro fails on
main):(All 3 replies + 2 control frames were shoved onto a socket pinned at 9 MiB bufferedAmount — the unbounded-buffering bug.)
Post-fix:
AI Review Report
Reviewed: (1) ordering — the queue never lets a new frame jump a parked backlog (explicit test); (2) no silent loss on the seq-less legacy text path (parked+drained, close-on-overflow forces snapshot replay, never drop); (3) keystroke path never drops (bounded queue + close-on-overflow, not discard); (4) the existing binary drop+resync path is not double-gated; (5) lazy queue creation + disposal on
destroy()/cleanupSocketListeners(no leak); (6) defensive coercion so a ws without a numericbufferedAmounttreats backpressure as clear rather than stranding frames. Cross-platform: pure transport logic on the Nodewspackage (main process) — no paths, shells, shortcuts, or Electron platform APIs; identical on macOS/Linux/Windows. This is squarely the SSH/remote path.Security Audit
No new input handling, command execution, path handling, auth, or secrets surface. Frames remain E2EE-encrypted (
encrypt/encryptBytes) exactly as before — the queue holds already-encrypted bytes/strings and only reorders nothing. The hard byte cap (64 MiB) bounds per-connection memory, so a malicious/buggy peer that refuses to drain can no longer drive unbounded main-process RSS — it now triggers a bounded close instead. Overflow close codes (1013 server / connection fail client) carry no sensitive data.Notes
MAX_BINARY_BUFFERED_AMOUNT(8 MiB); hard cap is 8× that. Both are defaults on the shared queue and easy to tune.e2ee_ready/e2ee_authenticated/e2ee_error) still send directly — they fire only during handshake, before any streaming reply, so there is no reordering relative to queued replies.ExecutionHostHealthhas no such member). The self-healing close→reconnect→snapshot path recovers transparently; adding a degraded-link UI indicator is a separable follow-up.Made with Orca 🐋