Skip to content

fix(remote): gate ws sends with an order-preserving backpressure queue#8073

Closed
Jinwoo-H wants to merge 1 commit into
mainfrom
fix/remote-terminal-send-backpressure
Closed

fix(remote): gate ws sends with an order-preserving backpressure queue#8073
Jinwoo-H wants to merge 1 commit into
mainfrom
fix/remote-terminal-send-backpressure

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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 yes over a slow SSH/relay tunnel) ballooned ws.bufferedAmount and main-process RSS without bound:

  • Server text/JSON reply path (e2ee-channel.ts encryptedReply): had no bufferedAmount gate — only the binary path did (encryptedBinaryReply). A legacy client without the terminalBinaryStream capability streams terminal output as JSON via terminal.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.
  • Client binary send path (remote-runtime-client.ts sendBinary): carries keystrokes and must never drop. It also had no bufferedAmount guard, 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):

  • 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 cap (64 MiB) of queued bytes is exceeded (link effectively dead) does it fire 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.
  • Generic over frame type: serves the text path (encrypted base64 strings) and the binary path (Buffer frames).

Wired in two places:

  • e2ee-channel.ts: encryptedReply enqueues through the queue; overflow → onError(1013, ...) (Try Again Later) closes the channel. Disposed in destroy().
  • remote-runtime-client.ts: sendBinary enqueues through the queue; overflow → fail(...) closes the socket (renderer resubscribes). Disposed in cleanupSocketListeners.

The existing binary drop+resync path (#8059, encryptedBinaryReply) is deliberately untouched — no double-gating, no deadlock.

Behavior under congestion (per path)

Path Before After
Server text/JSON reply (legacy terminal.subscribe) Unbounded ws.send → bufferedAmount/RSS balloon Replies parked in order, drained on recovery; wedged link → 1013 close → reconnect + fresh snapshot. No loss.
Client binary send (keystrokes/resize/snapshot-req) Unbounded ws.send → bufferedAmount/RSS balloon Input parked in order, drained on recovery; wedged link → socket fail → resubscribe + fresh snapshot. Keystrokes never dropped.
Server binary output (multiplex) Drop + client seq-gap resync (#8059) Unchanged (not routed through this queue).

Screenshots

No visual change.

Testing

  • pnpm typecheck (green)
  • oxlint on all changed files (clean)
  • pnpm test for affected suites: src/main/runtime/rpc/**, remote-runtime-client, runtime-environments (IPC), plus the new queue + text-path repro — 571 + 7 tests pass.
  • Deterministic repro-first tests (no wall-clock races): ws-outbound-backpressure-queue.test.ts (injected bufferedAmount + timer) and e2ee-channel-text-backpressure.test.ts (fake ws with controllable bufferedAmount).

Pre-fix (text-path repro fails on main):

× holds text replies while over the buffer cap and drains them in order
AssertionError: expected 5 to be 2
    expect(ctx.ws.sent.length).toBe(baseline)

(All 3 replies + 2 control frames were shoved onto a socket pinned at 9 MiB bufferedAmount — the unbounded-buffering bug.)

Post-fix:

✓ holds text replies while over the buffer cap and drains them in order
✓ sends straight through when the socket is not congested
Test Files  2 passed (2)  |  Tests  7 passed (7)

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 numeric bufferedAmount treats backpressure as clear rather than stranding frames. Cross-platform: pure transport logic on the Node ws package (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

  • Soft cap mirrors the existing MAX_BINARY_BUFFERED_AMOUNT (8 MiB); hard cap is 8× that. Both are defaults on the shared queue and easy to tune.
  • Control handshake frames (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.
  • Deliberately out of scope: surfacing a user-facing "congested" connection-health state (the audit noted ExecutionHostHealth has 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 🐋

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

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds 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)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding an order-preserving backpressure queue for WebSocket sends.
Description check ✅ Passed The description matches the template and includes summary, screenshots, testing, AI review, security audit, and notes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

❤️ Share

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

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

🧹 Nitpick comments (1)
src/shared/ws-outbound-backpressure-queue.test.ts (1)

109-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the not-writable + under-cap enqueue path.

The suite tests unwritable mid-park (drain path) but not enqueue when the socket is unwritable while bufferedAmount is under the soft cap. That combination hits the fast-path guard flagged in ws-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

📥 Commits

Reviewing files that changed from the base of the PR and between e4c7aab and eb316f3.

📒 Files selected for processing (5)
  • src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts
  • src/main/runtime/rpc/e2ee-channel.ts
  • src/shared/remote-runtime-client.ts
  • src/shared/ws-outbound-backpressure-queue.test.ts
  • src/shared/ws-outbound-backpressure-queue.ts

@Jinwoo-H

Copy link
Copy Markdown
Contributor Author

Superseded by #8141, which combines all ten remote-reliability audit fixes, incorporates the valid CodeRabbit follow-ups, and has full CI plus live Electron/SSH validation. Closing this standalone PR so #8141 is the single review target; this fix remains included there.

@Jinwoo-H Jinwoo-H closed this Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant