Files
stablyai--orca/docs/reference/ssh-typing-latency-under-relay-load.md
2026-07-13 13:05:33 +08:00

6.0 KiB

SSH Typing Latency Under Relay Load

Date: 2026-07-06

Symptom

Typing in an SSH-host terminal can become extremely slow (hundreds of ms to seconds per echoed keystroke) while other SSH work is active — file previews, source-control refresh, search. A quiet SSH shell stays snappy, which made the reports look unreproducible.

This is distinct from the local warm-switch lag documented in terminal-switch-typing-lag-investigation.md (daemon listSessions() snapshot cost — already fixed; TerminalHost.listSessions() is now metadata-only via getAppliedSize()).

Root Cause

The relay and the Electron client share ONE ordered SSH channel for all JSON-RPC traffic: PTY input/output, file streams, git responses, search, port scans. Two mechanisms turned bulk traffic into typing latency:

  1. Relay-side head-of-line blocking (primary). The fs.readFileStream pump (src/relay/fs-handler-file-read.ts) wrote every 256KB chunk (~340KB framed) into the relay's stdout as fast as local disk reads completed, ignoring the write() === false backpressure signal. A 10MB preview enqueued ~13.6MB into the pipe at once; a pty.data echo emitted mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds of echo delay per open file. The reproduced measurement: 4,195,592 bytes (the entire remaining 3MB test file, framed) queued ahead of one echo frame.

  2. Client-side O(n²) frame buffering (secondary). FrameDecoder.feed() in both src/main/ssh/relay-protocol.ts and src/relay/protocol.ts did Buffer.concat([buffered, chunk]) per data event, re-copying the whole backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame, ~80MB per 10MB file — on the Electron main thread, delaying pty:write IPC dispatch and echo delivery to the renderer.

Fix

  • Bulk lane with sink backpressure (RelayDispatcher.notifyBulk): fs.streamChunk frames are serialized per client and each send waits for the sink's drain when write() returns false. Interactive frames (pty.data) still use plain notify() and are admitted immediately — because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps ahead of every not-yet-admitted chunk. Sinks (relay.ts stdout + Unix socket clients) surface write()'s boolean and a one-shot waitWriteDrain; every death path (EPIPE, stdin end, detach, dispose) flushes parked waiters so a pump can never hang on a dead pipe.

  • Credit-based flow control (fs.streamAck): the client requests streams with flowControl: 'ack' and acks each processed chunk; the relay caps unacked chunks at STREAM_ACK_WINDOW_CHUNKS = 4 (~1MB raw). This bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP buffers) and paces the relay's base64/JSON encode loop so incoming keystroke frames get event-loop turns. A parked pump wakes on ack, cancel, release, client detach, and a 1s staleness recheck.

  • Cross-version compatibility: old client + new relay → no flowControl param → legacy unpaced pump (still drain-bounded, which is transparent). New client + old relay → fs.streamAck is an unknown notification and is silently ignored. STREAM_CHUNK_SIZE (256KB) is intentionally unchanged — both sides bake it into chunk offset math, so changing it would corrupt cross-version streams.

  • FrameDecoder rewrite (both mirrored protocol files): chunk-list buffering; each frame is assembled exactly once (one copy) instead of re-concatenating the backlog per feed.

What Stays Fast / Unchanged

  • Relay PTY output batching (interactive echo fast path in src/relay/pty-handler.ts) is untouched.
  • No listSessions() / provider inventory was added anywhere near typing, focus, switch, resume, resize, or render paths.
  • Remote file streaming correctness: per-chunk length checks, chunk-count and byte-count invariants, cancel paths, and the 12MB round-trip integration test all pass unchanged.

Regression Coverage

  • src/relay/fs-stream-pty-echo-backpressure.integration.test.ts — real mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed chunks when congested (pre-fix: whole file), ack window caps in-flight chunks, legacy no-ack clients still get complete streams.
  • src/relay/fs-handler-stream.test.ts — pump parks at the ack window, resumes per ack, and releases its file handle when cancelled while parked.
  • src/relay/dispatcher.test.ts — notifyBulk semantics: drain gating, per-client targeting, dispose releases parked senders, interactive notify() not gated behind a stalled bulk lane.
  • src/main/ssh/relay-protocol.test.ts — decoder byte-at-a-time boundary straddling, oversized-frame resync across odd chunk sizes, and a no-Buffer.concat-during-feed guard that locks out the O(n²) shape.
  • tests/e2e/ssh-docker-relay-perf.spec.ts — new "busy relay" scenario: types while two 8MB remote file-read loops and a git.status loop run; budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix HOL is only tens of ms — the in-process byte-bound test is the authoritative red/green; the e2e guards the end-to-end wiring.)

Residual Gaps

  • Large single-frame responses: git.* responses (stdout capped at MAX_GIT_BUFFER = 10MB → ~13MB framed) and other request/response results are one atomic frame on the wire; a huge diff can still delay an echo by its own transfer time. Fixing this requires response chunking (a protocol change); bounded by the 16MB MAX_MESSAGE_SIZE.
  • Outbound direction: a large fs.writeFile request frame (client → relay) can queue ahead of keystroke pty.data frames on channel.stdin. Same shape, opposite direction, rarer trigger (saving a large remote file while typing).
  • pty.ackData flow control for PTY output remains unenforced (src/relay/pty-handler.ts — "not yet enforced"); PTY output floods are already paced by the relay's 8ms batch/16KB slice scheduler.