Skip to content

fix(remote): resync remote terminal on dropped output frames#8059

Closed
Jinwoo-H wants to merge 1 commit into
mainfrom
fix/remote-terminal-frame-drop-resync
Closed

fix(remote): resync remote terminal on dropped output frames#8059
Jinwoo-H wants to merge 1 commit into
mainfrom
fix/remote-terminal-frame-drop-resync

Conversation

@Jinwoo-H

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

Copy link
Copy Markdown
Contributor

Summary

Remote terminals over a congested link (fast PTY such as a build log or yes over a slow/SSH transport) could silently corrupt — missing or garbled output with no error and no recovery.

Root cause: the binary multiplex output path drops Output frames when the websocket send buffer exceeds MAX_BINARY_BUFFERED_AMOUNT (8 MiB) — encryptedBinaryReply returns false and the frame is never sent. The server ignored that result and kept producing, and the client had no gap check, so a dropped frame vanished forever.

Fix (self-healing, no scary UI):

  • Client (remote-runtime-terminal-multiplexer.ts): Output seq is a byte high-water, so a drop is self-describing on the wire. The client now tracks the expected next offset per stream. On a gap (seq - rawLength > expectedSeq) it discards the corrupt tail, suppresses live output, and requests a fresh authoritative snapshot (reusing the existing initial-snapshot machinery) that fully re-syncs the terminal. expectedSeq re-aligns to the snapshot's seq.
  • Server (methods/terminal.ts): seq-less Output chunks (intermediate split chunks) now carry the sentinel seq: 0 (== "no seq") instead of the control-frame cursor++ value, which would otherwise poison the client's gap detector. sendFrame now returns the send/drop result and the boolean is propagated end-to-end (dispatcher.ts sendBinary type).

Cross-version compatibility

Client Server Behavior
new new Full self-healing resync on any dropped frame.
old new No worse than today. Server sentinel-0 for seq-less chunks matches the old "no seq" convention the old client already ignored; old client still can't detect gaps (unchanged pre-existing behavior).
new old No worse than today. Old server may tag seq-less split chunks with a small cursor value; new client only treats seq > 0 chunks as gap candidates and never lowers expectedSeq, so a stray small cursor can't trigger a false resync. Real drops that jump seq forward are still caught.
old old Unchanged (the pre-existing bug).

Screenshots

No visual change.

Testing

  • pnpm typecheck (green)
  • oxlint on all changed files (clean)
  • pnpm test for the affected suites: terminal-multiplex, terminal-multiplex-escape-tail, terminal-output-batching, terminal-subscribe-buffer, runtime-terminal-stream, terminal-stream-protocol, and the new repro — all pass.
  • Added a deterministic end-to-end repro test (remote-runtime-terminal-frame-drop-resync.test.ts) that drives the real client multiplexer through a fake backpressured transport.

Pre-fix (repro fails on main):

× detects a dropped Output frame via the seq gap and resyncs
AssertionError: expected [ 'aaa', 'ccc' ] to deeply equal [ 'aaa' ]
  [
    "aaa",
+   "ccc",   // corrupt post-gap tail rendered as live data; no resync
  ]

Post-fix:

✓ detects a dropped Output frame via the seq gap and resyncs
✓ passes contiguous output straight through without resyncing
Test Files  1 passed (1)  |  Tests  2 passed (2)

AI Review Report

Reviewed the seq semantics end-to-end (byte high-water vs. control-frame cursor), the resync state machine (no stuck resyncInFlight on error/failed-send paths), and interaction with in-flight user serializeBuffer snapshot requests (guarded by the resyncInFlight latch and independent requestId matching). Cross-platform: change is pure transport/protocol logic (no paths, shells, shortcuts, or Electron platform APIs), so macOS/Linux/Windows behavior is identical; this is squarely the SSH/remote path. No new lint suppressions; no max-lines bumps; test file named after the behavior.

Security Audit

No new input handling, command execution, path handling, auth, secrets, or dependency surface. The resync request reuses the existing authenticated SnapshotRequest opcode over the already-encrypted e2ee channel; the byte high-water seq is bounded and only used to compare offsets. Snapshot byte budgets and the existing 2 MiB client cap remain in force, so a malicious/buggy server can't force unbounded buffering via resyncs.

Notes

Deliberately out of scope: the server does not additionally pause per-stream enqueue while over the buffer cap (the task listed this as optional). The client-driven resync is the authoritative self-healing mechanism and works across the wire without a server-side drain callback; adding producer-side pausing is a separable optimization and can follow up if backpressure churn proves costly.

Made with Orca 🐋

The multiplex output path silently drops Output frames when the
websocket buffer exceeds MAX_BINARY_BUFFERED_AMOUNT (encryptedBinaryReply
returns false). The client never noticed the gap, so a congested link
(fast PTY over slow/SSH transport) permanently corrupted the remote
terminal with missing/garbled output and no error.

Output frame seq is a byte high-water, so a drop is self-describing on
the wire. The client now tracks the expected next offset and, on a gap,
discards the corrupt tail and pulls a fresh authoritative snapshot
(self-healing, no user-facing error). Server-side, seq-less Output
chunks now carry the sentinel 0 instead of the control-frame cursor so
they don't poison the client's gap detector, and sendFrame propagates
the send/drop result end-to-end.

Adds an end-to-end repro test driving the real client multiplexer
through a fake backpressured transport.

Co-authored-by: Orca <help@stably.ai>
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Terminal multiplex streaming now accepts and propagates binary-send success results, returns failure when streams are closed, and assigns sequence values differently for output and control frames. The remote terminal multiplexer tracks expected output sequences, detects gaps, requests recovery snapshots, and suppresses output during resynchronization. New integration tests simulate dropped and contiguous frames to verify recovery behavior. Existing terminal tests were structurally reformatted without changing their assertions or control flow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: remote terminal resync on dropped output frames.
Description check ✅ Passed The description includes all required sections and covers summary, testing, cross-platform review, security, and notes.
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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/main/runtime/rpc/methods/terminal.ts (1)

1234-1237: 🩺 Stability & Availability | 🔵 Trivial

Consider logging/counting dropped sendBinary frames for observability.

sendFrame's boolean result is computed but never consumed here, so a dropped Output frame due to backpressure leaves no server-side signal (only the client can infer it via seq gaps). A lightweight counter/log on sent === false would help diagnose how often backpressure drops occur in production.

src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts (1)

81-95: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a multi-byte/non-ASCII output case to catch byte-vs-code-unit offset bugs.

All fixtures here ('aaa', 'bbb', 'ccc', etc.) are pure ASCII, so text.length happens to equal the UTF-8 byte length and can't expose a byte-offset vs. string-length mismatch. Given the companion critical finding in remote-runtime-terminal-multiplexer.ts (gap detection uses data.length instead of frame.payload.byteLength), a test case with a multi-byte character (e.g. an emoji or accented character) straddling a drop would directly validate the fix and prevent regression.

Also applies to: 142-160


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0b14e577-928e-4dcd-aa95-2b673286604d

📥 Commits

Reviewing files that changed from the base of the PR and between 3f9dbba and 1f768d3.

📒 Files selected for processing (8)
  • src/main/runtime/rpc/dispatcher.ts
  • src/main/runtime/rpc/methods/terminal.ts
  • src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts
  • src/main/runtime/rpc/terminal-multiplex.test.ts
  • src/main/runtime/rpc/terminal-output-batching.test.ts
  • src/main/runtime/rpc/terminal-subscribe-buffer.test.ts
  • src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts
  • src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts

Comment on lines +421 to +435
// Why: the snapshot is the new authoritative byte high-water; align the
// gap detector to it and re-open the live path (used by both the initial
// snapshot and a frame-drop resync, which reuses the 'initial' target).
if (target === 'initial') {
stream.expectedSeq = typeof info?.seq === 'number' ? info.seq : undefined
stream.resyncInFlight = false
stream.initialSnapshotReceived = true
stream.callbacks.onSubscribed?.()
}
return
}
if (frame.opcode === TerminalStreamOpcode.Error) {
clearSnapshot(stream)
// Why: a failed resync must re-open the live path or output stalls forever.
stream.resyncInFlight = false

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.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A15 'const sendRequestedSnapshot' src/main/runtime/rpc/methods/terminal.ts
rg -n 'pendingSnapshotRequest|resyncInFlight|snapshotTarget' src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts

Repository: stablyai/orca

Length of output: 2287


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- remote-runtime-terminal-multiplexer.ts (snapshot/resync flow) ---'
sed -n '340,520p' src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts

echo
echo '--- terminal.ts (requested snapshot server path) ---'
sed -n '1346,1415p' src/main/runtime/rpc/methods/terminal.ts

Repository: stablyai/orca

Length of output: 10007


Resync can be misclassified by an in-flight manual snapshot

src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts:365, 462-481 — a gap-triggered SnapshotRequest has no requestId, so if pendingSnapshotRequest is already set, its SnapshotEnd is routed as a normal request response instead of the resync. That resolves the wrong promise and leaves resyncInFlight stuck true, so live output keeps being dropped. Prioritize resyncInFlight in snapshotTarget, or prevent resync while a manual snapshot is pending.

@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