Skip to content

fix(remote): harden terminal, relay, and SSH reliability#8141

Merged
Jinwoo-H merged 19 commits into
mainfrom
fix/remote-reliability-audit
Jul 10, 2026
Merged

fix(remote): harden terminal, relay, and SSH reliability#8141
Jinwoo-H merged 19 commits into
mainfrom
fix/remote-reliability-audit

Conversation

@Jinwoo-H

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

Copy link
Copy Markdown
Contributor

Summary

Combines and supersedes the ten remote-reliability audit PRs into one reviewed integration:

Supersedes #8059, #8060, #8067, #8070, #8073, #8091, #8092, #8094, #8096, and #8099.

Integration review

Reviewed the ten changes together for ordering, lifecycle, ownership, and cross-feature interactions. Follow-up fixes in this branch:

  • serialize explicit snapshots with frame-gap recovery, including initial replay
  • preserve the terminal sequence contract in JavaScript UTF-16 units and cover non-ASCII output
  • guard git stream ACKs during disposal, enforce client ownership for ACK/cancel, target terminal frames to the owner, and validate sentinels strictly
  • prevent websocket fast-path writes while unwritable and drain queued sends with an O(1) head index
  • finish deferred subscription closes even when replay fails
  • strictly cap oversized log writes and cover stdout, fallback, and Windows rename-lock behavior
  • keep PTY exit/readiness notifications immediate while coalescing only title/status churn
  • cover stale geometry handles and interpreter wrappers with no live child

These changes address the substantive CodeRabbit findings across the source PRs. The suggested UTF-8 byte offset change was not applied because the producer's sequence values intentionally use JavaScript string lengths; a multibyte regression test now locks that wire contract down.

Automated validation

  • Full isolated Node 24 suite before the final main rebase: 6,803 test files passed, 26,554 tests passed, 33 skipped, 0 failed
  • Post-rebase changed surface under Node 24: 25 files passed, 967 tests passed, 2 skipped
  • OrcaRuntimeService suite: 592/592 passed
  • Typecheck: passed
  • Max-lines ratchet: passed
  • git diff check: passed
  • Relay builds: linux x64/arm64, darwin x64/arm64, win32 x64/arm64, and WSL all passed

Full lint reaches an unrelated current-main localization mismatch: ja.json key components.native-chat.composer.uploadingAttachments lacks {{value1}}. This branch has no locale diff.

Live Electron + SSH validation

Tested the exact branch in an isolated Electron profile against a throwaway Linux arm64 Docker SSH host and a real git repo. GitHub clone auth was unavailable, so the documented real-repo bundle fallback was used.

  • verified the running app identity matched fix/remote-reliability-audit and this worktree
  • registered the SSH repo visibly and opened its terminal through the rendered workspace UI
  • typed through the real xterm surface; the remote shell rendered the correct cwd and ORCA_REMOTE_PTY_OK, with the backing marker present over SSH
  • resized the live PTY from 133x59 to 100x35; rendered stty size returned 35 100
  • streamed a real 4,234,997-byte staged git diff over renderer IPC in 117 ms, complete through its final line
  • delivered WRAPPED_PROMPT_OK to a Python-wrapped agent process over the real SSH PTY; the visible terminal rendered WRAPPER_CAPTURED
  • negative safety case at bare bash returned false after readiness polling and did not write or execute the marker
  • confirmed the deployed relay used --log-file, produced a healthy bounded log, and had no transport/runtime errors
  • restored the remote repo clean, removed the Orca host/repo records, and deleted the Electron profile, Docker host, keys, and test processes

Performance and bounded-resource review

  • session tab churn coalesces 20 updates into one snapshot fan-out
  • the 4.23 MB live SSH diff completed in 117 ms; integration coverage keeps PTY echo bounded to 342 KB
  • websocket draining is O(1)
  • git pending data, websocket queues, replay windows, timers, and relay log storage all have explicit bounds and cleanup paths

Remaining platform scope

The real transport test covered macOS host to Linux arm64 SSH target. Windows relay artifacts build successfully and the Windows rename-lock fallback is unit-tested, but no physical Windows host was used in this session.

Made with Orca 🐋

Jinwoo-H and others added 18 commits July 10, 2026 14:52
Large git diff/exec responses (up to MAX_GIT_BUFFER=10MB) were sent as one
JSON-RPC frame on the shared SSH channel. encodeJsonRpcFrame's synchronous
JSON.stringify + Buffer.from of the whole payload, plus the multi-MB write,
head-of-line-blocked interactive pty.data echo: opening a big diff (e.g. a
lockfile) over SSH while typing in another pane stalled the echo for seconds.

The 2026-07 fs.readFileStream fix added a per-client serialized bulk lane
(RelayDispatcher.notifyBulk) honoring write()===false + waitWriteDrain, plus an
ack credit window — but only fs streams used it; git responses never did.

Stream the diff family (git.diff/branchDiff/commitDiff) and git.exec through the
bulk lane in chunks with the same ack-window backpressure. The relay returns a
small sentinel result and pumps git.responseChunk frames; the client reassembles
and JSON.parses. Small responses stay single-frame.

Cross-version safe via an opt-in capability gate (no handshake change):
- client sends __streamResponse:true; old relays ignore it → single-frame.
- new relays only stream when the flag is present → old clients get single-frame.
- new client checks for the stream sentinel; a plain result (old relay) is
  returned directly.

STREAM_CHUNK_SIZE is untouched; git chunking uses its own GIT_RESPONSE_CHUNK_SIZE
and the client reassembles by concatenation, so chunk size is not baked into any
cross-version offset math.

Co-authored-by: Orca <help@stably.ai>
… for git response streams

Address self-review findings on the git response streaming reader:
- MEDIUM: mux.request's timeout only bounded the sentinel; the reassembly phase
  had no deadline, so a relay pump that broke on staleness (sends no
  responseEnd) while the SSH channel stayed up would hang the client forever.
  Add a client-side inactivity timeout that resets on each chunk and rejects if
  no stream frame arrives within the window (default 30s, matching the mux
  request timeout). New regression test drives a post-sentinel stall.
- LOW: widen the options type to declare timeoutMs (sentinel) and a distinct
  inactivityTimeoutMs (reassembly); forward only mux-request options to the
  sentinel request.
- LOW: cap the pre-sentinel pending-frame buffer (MAX_PENDING_FRAMES) so
  concurrent streams cannot transiently buffer each other's chunks unbounded;
  foreign frames are dropped on drain anyway.

Co-authored-by: Orca <help@stably.ai>
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>
A shared-control keepalive frame refreshed the timeout of every pending
request on the shared socket. Those pending requests are ordinary short
RPCs (git/repo/session-tabs), while the keepalive is armed server-side
only for an unrelated long-poll on the same socket. So while any long-poll
kept keepalives flowing, a genuinely-stuck short RPC never hit its
deadline: the caller hung forever, the timeout-driven teardown/reconnect/
replay never fired, and eight such hangs exhausted the per-environment
call queue (concurrency 8), wedging all further control-plane calls.

Tag each pending request with `refreshTimeoutOnKeepalive` (default false).
Short RPCs now keep an absolute deadline so a stuck server call times out,
tears the socket down, and reconnects/replays as designed. Only requests
that explicitly opt in (long-polls routed through the short-RPC path) may
have their deadline extended by keepalives — preserving that escape hatch
without changing any current caller, since today's long-polls run on the
subscription map, not pendingRequests.

Applies to both SSH remote-runtime and remote Orca Server topologies,
which share this control connection.
terminal.send was already hardened (#7827) to resolve handles through the
guarded runtime.resolveLiveLeafForHandle, which throws terminal_handle_stale
when a pane's PTY was replaced under a handle (restart/re-spawn bumps
ptyId/generation). The geometry family still used the UNGUARDED
resolveLeafForHandle and then mutated PTY state:

  - terminal.resizeForClient
  - terminal.setDisplayMode
  - terminal.restoreFit
  - terminal.updateViewport

The unguarded resolver returns the pane's CURRENT pty, so a remote client
holding a stale handle resized/refit/viewported the WRONG (new) pty —
mysterious geometry corruption on the fresh session.

Switch all four to resolveLiveLeafForHandle. The return shape is identical
({ ptyId }), so no follow-on logic changes. The read-only
terminal.getDisplayMode, the already-guarded multiplex path, and the legacy
subscribe-time resolution are left untouched.

Clients already handle terminal_handle_stale gracefully (no red banners):
updateViewport/restoreFit/setDisplayMode callers swallow the error, and the
remote-runtime transport classifies terminal_handle_stale as "terminal gone"
→ retires the mirror and re-derives the handle from the next session-tabs
snapshot. Applies to both SSH remote-runtime and remote Orca Server, which
share these control-plane RPCs.
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>
… leaked subscription

Co-authored-by: Orca <help@stably.ai>
…minal error

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

Co-authored-by: Orca <help@stably.ai>
…us churn

Spinner-in-title agents (e.g. the Claude Code braille spinner) flip a PTY
title several times per second. Each flip called touchMobileSessionSnapshotsForPty,
which bumped every PTY-backed snapshot's version and immediately fanned a fresh
session.tabs emit out to every subscriber — where the ws layer JSON.stringifies
it per client. That is O(clients × snapshot size) of churn work with no debounce,
while files.watch batches at 150ms and native-chat debounces at 40ms.

Route the title/status churn path through a small per-worktree trailing-edge
coalescer (50ms window, 250ms max-wait cap). Clients gate on snapshotVersion
freshness, so collapsing intermediate versions is safe — the emit re-reads the
latest snapshot, so only the freshest version ships. Structural changes
(tab add/remove/activate) still emit immediately and cancel any pending
coalesced notify. Pending notifies flush on subscription close and are dropped
on worktree removal so no final state is lost or lands stale.
writeFrame caught a write throw and, for the PRIMARY client, only logged and
set closed=true WITHOUT notifying detach — unlike non-primary clients which are
detached. The framing carries seq/ack but has no retransmit buffer, so the frame
(possibly pty.data or pty.exit) was lost, and because detach never fired the
owning Orca kept treating the primary as alive until the ~20s keepalive timeout,
silently missing output or a pane death in the meantime.

Every other primary-death path (stdin end/error, stdout error) already calls
invalidateClient() -> notifyClientDetached. Make the socket-write-throw path
consistent: fire notifyClientDetached so the reconnect + PTY-reattach machinery
runs promptly. Reattach replays the PTY output buffer and surfaces pane death via
reattach-not-found, so pty.exit is never lost. This fail-fast approach reuses the
tested reconnect path instead of adding a seq-keyed resend buffer to the hot path.

Regression test asserts the primary is detached on write throw (fails pre-fix)
and recovers via setWrite.

Co-authored-by: Orca <help@stably.ai>
relay.log grows unbounded on long-lived relays: the detached launch redirects
> relay.log 2>&1, which truncates only at relaunch, so per-stream stderr lines
and reconnect flaps accumulate forever and can fill small remote hosts.

Add an in-process RotatingLogWriter (the relay owns its own stderr; a shell
redirect can't cap size). installRelayLogRotation wraps process.stdout/stderr so
all logging (42 call sites) flows through a writer that appends to relay.log,
tracks size, and at 10MB renames relay.log -> relay.log.1 (one archived
generation) and reopens a fresh relay.log. Wired only for the detached daemon;
deploy passes --log-file on the POSIX and Windows launch paths.

Constraints: portable node:fs sync calls (works with pipe/redirect on
Linux/macOS/Windows); the current log is always relay.log so the tail-diagnostics
workflow keeps working; writes and rotation are guarded so a filesystem error
degrades to uncapped logging rather than crashing, and rename (not delete) means
no window without a log file.

Tests cover rotation at the cap, tail-ability, boot-output preservation, ~2x cap
footprint bound, and stderr wrapping/restore.

Co-authored-by: Orca <help@stably.ai>
Self-review found a HIGH cross-platform bug: on Windows the launch shell holds
its own `1>relay.log` handle without FILE_SHARE_DELETE, so renameSync of the
live file fails (EPERM/EBUSY). The previous rotate() swallowed that, reopened the
un-renamed file, and re-read its full over-cap size — so the cap never held on
Windows AND every subsequent log line churned a failing close/rename/open.

On rename failure, fall back to truncating relay.log in place ('w') so the size
cap still holds and there is no per-line churn. The shell's fd keeps appending at
its own offset but truncation bounds total growth; trade-off is no archived
generation on that path. New test models the rename-blocked case (archive path is
a directory) and asserts the cap still holds (fails pre-fix: 20100 > 4096).

Also fixed a test nit: spy on process.stderr so the post-restore assertion write
does not leak to the real test-runner stderr.

Co-authored-by: Orca <help@stably.ai>
…agents (aider/vibe)

Co-authored-by: Orca <help@stably.ai>
… is dropped

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

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 79669e3b-ac24-47f5-a643-6a7a77580660

📥 Commits

Reviewing files that changed from the base of the PR and between d41b468 and 4d49b18.

📒 Files selected for processing (4)
  • src/relay/dispatcher.test.ts
  • src/relay/dispatcher.ts
  • src/relay/git-response-stream-ownership.test.ts
  • src/relay/git-response-stream.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/relay/dispatcher.ts
  • src/relay/git-response-stream.ts

📝 Walkthrough

Walkthrough

The changes add streamed Git response handling across SSH and relay layers, including chunking, acknowledgements, cancellation, and reassembly. WebSocket backpressure queues protect encrypted replies and subscription traffic. Mobile session notifications are coalesced, terminal streams recover from dropped frames, stale terminal handles are rejected, relay logs rotate, shared-control replay and timeout state is refined, and agent startup failures surface toast feedback.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it misses several required template sections like Screenshots, Testing checklist, AI Review Report, Security Audit, and Notes. Add the missing template sections, including Screenshots or No visual change, the testing checklist, AI Review Report, Security Audit, and Notes.
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately reflects the PR's main reliability hardening across terminal, relay, and SSH paths.
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/orca-runtime.ts (1)

5232-5237: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

flushAll() on every unsubscribe fans a settled emit out to all remaining listeners.

Flushing before removing the listener does let a subscriber closing mid-window receive the latest state, but flushAll() also force-emits pending coalesced notifications for every worktree to every other still-subscribed listener, collapsing the coalescing window for them too. If the coalescer supports per-listener or targeted flushing this could be narrowed; otherwise it's an acceptable, minor eager-emit. Not blocking.

src/relay/dispatcher.ts (1)

540-552: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider aborting in-flight requests in the write-failure catch path for full consistency.

The comment states the write-throw path is now "consistent with" invalidateClient() and detachClient(), but both of those also call this.requestAborts.abortClient(client.id) to cancel in-flight handlers. Without the abort, long-running handlers for the failing client continue executing unnecessarily — isStale() prevents stale responses but doesn't cancel work or signal AbortControllers.

♻️ Add abort call to match invalidateClient/detachClient
     } catch (err) {
       client.closed = true
       client.generation++
+      this.requestAborts.abortClient(client.id)
       this.flushDrainWaiters(client)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5c2dad34-11fe-4a75-b701-efd14e2f4bbf

📥 Commits

Reviewing files that changed from the base of the PR and between 1afec6c and d41b468.

📒 Files selected for processing (51)
  • src/main/providers/ssh-git-provider.test.ts
  • src/main/providers/ssh-git-provider.ts
  • src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts
  • src/main/runtime/mobile-session-tabs-notify-coalescer.ts
  • src/main/runtime/mobile-subscribe-integration.test.ts
  • src/main/runtime/orca-runtime.test.ts
  • src/main/runtime/orca-runtime.ts
  • src/main/runtime/rpc/dispatcher.ts
  • src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts
  • src/main/runtime/rpc/e2ee-channel.ts
  • src/main/runtime/rpc/methods/terminal.ts
  • src/main/runtime/rpc/terminal-geometry-stale-handle.test.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-send.test.ts
  • src/main/runtime/rpc/terminal-subscribe-buffer.test.ts
  • src/main/ssh/relay-protocol.test.ts
  • src/main/ssh/relay-protocol.ts
  • src/main/ssh/ssh-git-response-stream-reader.ts
  • src/main/ssh/ssh-relay-deploy.ts
  • src/relay/dispatcher.test.ts
  • src/relay/dispatcher.ts
  • src/relay/git-handler.ts
  • src/relay/git-response-pty-echo-backpressure.integration.test.ts
  • src/relay/git-response-stream-ownership.test.ts
  • src/relay/git-response-stream.ts
  • src/relay/protocol.ts
  • src/relay/relay.ts
  • src/relay/rotating-log-writer.test.ts
  • src/relay/rotating-log-writer.ts
  • src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts
  • src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts
  • src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
  • src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
  • src/renderer/src/lib/agent-followup-delivery.test.ts
  • src/renderer/src/lib/agent-followup-delivery.ts
  • src/renderer/src/lib/new-workspace.test.ts
  • src/renderer/src/lib/new-workspace.ts
  • src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts
  • src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
  • src/shared/remote-runtime-client.ts
  • src/shared/remote-runtime-shared-control-connection.test.ts
  • src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts
  • src/shared/remote-runtime-shared-control-requests.ts
  • src/shared/remote-runtime-shared-control-state.ts
  • src/shared/remote-runtime-shared-control-subscriptions.test.ts
  • src/shared/remote-runtime-shared-control-subscriptions.ts
  • src/shared/remote-runtime-shared-control-types.ts
  • src/shared/ws-outbound-backpressure-queue.test.ts
  • src/shared/ws-outbound-backpressure-queue.ts

Comment thread src/relay/git-response-stream.ts
Co-authored-by: Orca <help@stably.ai>
@Jinwoo-H

Copy link
Copy Markdown
Contributor Author

Addressed the integrated review in 4d49b18:

  • wrapped the git stream error-notification path so a closed channel cannot create an unhandled rejection; added a double-rejection regression
  • abort in-flight request controllers immediately when any client write throws, matching invalidate/detach behavior; added a primary-client regression

I kept the non-blocking flushAll() unsubscribe behavior unchanged. It is an intentional delivery contract covered by the "flushes a pending notify when the last subscriber closes" integration test: the closing subscriber receives the freshest snapshot before removal. It can eagerly settle at most the bounded 50 ms/250 ms coalescing windows for other listeners, while unsubscribe frequency is lifecycle-bound rather than title-churn-bound. Narrowing that without losing the final delivery would require a separate per-listener pending snapshot path and is not justified by the measured hot path.

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