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:
-
Relay-side head-of-line blocking (primary). The
fs.readFileStreampump (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 thewrite() === falsebackpressure signal. A 10MB preview enqueued ~13.6MB into the pipe at once; apty.dataecho 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. -
Client-side O(n²) frame buffering (secondary).
FrameDecoder.feed()in bothsrc/main/ssh/relay-protocol.tsandsrc/relay/protocol.tsdidBuffer.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, delayingpty:writeIPC dispatch and echo delivery to the renderer.
Fix
-
Bulk lane with sink backpressure (
RelayDispatcher.notifyBulk):fs.streamChunkframes are serialized per client and each send waits for the sink'sdrainwhenwrite()returnsfalse. Interactive frames (pty.data) still use plainnotify()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.tsstdout + Unix socket clients) surfacewrite()'s boolean and a one-shotwaitWriteDrain; 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 withflowControl: 'ack'and acks each processed chunk; the relay caps unacked chunks atSTREAM_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
flowControlparam → legacy unpaced pump (still drain-bounded, which is transparent). New client + old relay →fs.streamAckis 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 atMAX_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 16MBMAX_MESSAGE_SIZE. - Outbound direction: a large
fs.writeFilerequest frame (client → relay) can queue ahead of keystrokepty.dataframes onchannel.stdin. Same shape, opposite direction, rarer trigger (saving a large remote file while typing). pty.ackDataflow 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.