mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* Improve mobile terminal typing and selection * Address mobile terminal review feedback * Remove dead native terminal input branch * Replay bracketed paste in terminal preamble * Stop per-key terminal resize claims * Stabilize terminal resize browser test * Use tracked terminal mode for native paste * Restore terminal toolbar focus handling * Adapt native terminal to active visibility state * Stabilize native mobile terminal interactions * Add native terminal fallback boundary * test(app): expect explicit terminal resize reclaim * fix(app): stabilize terminal buffer coordinates * fix(app): restrict terminal resize ownership * fix(terminal): gate restored input modes * fix(app): recover stalled terminal WebViews * chore(app): remove obsolete terminal harnesses * fix(app): make terminal resize claims explicit * test(app): stabilize native terminal device flows * test(app): track absolute scrollback coordinates * fix(app): isolate terminal WebView streams * fix(terminal): arbitrate size ownership across clients The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it. * test(server): make worker resize assertion portable Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
5.3 KiB
5.3 KiB
Terminal performance
How terminal output stays low-latency, what the invariants are, and how to measure before/after any change to the pipeline. Read this before touching anything under packages/server/src/terminal/ or packages/app/src/terminal/runtime/.
The pipeline
pty (node-pty, forked worker process)
→ headless xterm parse (worker, snapshot fidelity)
→ TerminalOutputCoalescer (worker, ≤1 IPC message per 5ms per terminal)
→ process.send IPC → daemon main process
→ TerminalOutputCoalescer (per client stream, terminal-session-controller.ts)
→ binary ws frame (2-byte header + raw bytes)
→ client decode (daemon-client.ts) → stream router → emulator runtime
→ xterm.write (back-to-back; xterm batches internally)
Terminal frames share the daemon main event loop with all agent traffic. The eventLoopDelay block in the ws_runtime_metrics log line (every 30s in daemon.log) is the ground truth for "the daemon is busy" — p99/max there directly bound worst-case terminal frame delay.
Invariants (the easy-to-break ones)
- Coalescers are leading+trailing throttles. The first chunk after an idle window flushes immediately (synchronously); only sustained bursts wait for the trailing timer. Reverting to trailing-only adds a full window (~5ms) to every keystroke echo.
- Output coalescing happens in the worker, before IPC. One
process.sendper pty chunk was a main-loop flood under build output. Non-output messages (snapshot/snapshotReady/titleChange/exit) must flush the coalescer first so ordering is preserved. - Coalesced output carries the LAST chunk's revision. Snapshot replay dedup (
replayTerminalOutputAfterSnapshot) skips buffered output withrevision <= replayRevision; a merged batch with a lower revision would be wrongly skipped (lost output). - The input-mode tracker runs once per process boundary, not per hop. The worker owns the authoritative tracker; the daemon caches the replay preamble from
getTerminalStateresponses andsnapshotReadymessages. Do not reintroduce a per-chunkfeed()on the daemon main loop. - Snapshot catch-up is backpressure-gated. A stream falls back to a full snapshot only when
outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES(256KB) and the client transport reportsbufferedAmount > MAX_CLIENT_BUFFERED_BYTES(4MB). A client that keeps draining streams continuously, no matter how much output is produced. Before this gate existed, every 256KB of build output dropped a frame and forced a full JSON cell-grid snapshot (~200k objects across IPC) — the historical source of spiky lag and GC hitches. - Client output writes are not serialized per frame. The emulator runtime drains contiguous plain writes straight into xterm (which buffers internally). Only barrier ops (
clear,snapshot,suppressInputwrites) wait — behind a zero-length sentinel write — so resets can't interleave with in-flight output. - Terminal size has one daemon-owned claimant. Focus and direct interaction send a
claim; later geometry changes from that connection sendupdate. A claim transfers ownership even when the dimensions are unchanged, while an update from any other connection is ignored. This lets an owning pane follow splits and keyboard insets without allowing an idle phone or browser to steal the PTY size.
Measuring
- Node-only benchmark (fast iteration, server pipeline):
npx tsx scripts/benchmark-terminal-latency.ts. Boots an isolated daemon (freshPASEO_HOME, random port — never 6767), measures echo latency percentiles, burst jitter, and snapshot counts under ramped mock-agent load. Writes JSON to/tmp/paseo-terminal-bench/. Healthy numbers (2026-06): echo p50 ~2.3ms, p95 ~3.3ms, a 2MB burst fully streamed withsnap=0. - Browser perf specs (user-perceived path): gated behind
PASEO_TERMINAL_PERF_E2E=1—packages/app/e2e/browser/terminal-performance.spec.tsandpackages/app/e2e/browser/terminal-keystroke-stress.spec.ts(per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst. - Production: grep
daemon.logforws_runtime_metricsand readeventLoopDelay+bufferedAmount. - Git pressure: the same log line includes
git.commands(limiter occupancy, queue age, queue wait, execution time, failures, timeouts, and top operations),git.workspaceService(daemon-global Git observer ownership), and per-session workspace Git subscription totals underruntime. Queue wait and execution time are separate because the Git command timeout begins only after a command acquires a limiter slot.
Known remaining contention (follow-up candidates)
- A single large
agent_streammessage (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread. - Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (
packages/relay/src/encrypted-channel.ts). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames. sendToClientre-stringifies session messages per socket; only matters for multi-socket connections.