18 KiB
Terminal Switch Typing Lag Investigation
Date: 2026-07-01
Scope
This note tracks the investigation into terminal input lag that appears after switching workspaces or terminals in a heavy packaged Orca profile.
The user-visible symptom is that typing after switching back to a workspace can feel delayed for about one second. Text may appear all at once after the delay. The issue reproduced in the user's main packaged Orca app, but not reliably in lighter dev profiles.
Current Reproduction Setup
- Main packaged Orca app is used for the meaningful repro.
- The main profile had roughly 150 terminals when the lag reproduced.
- The current worktree under test is
/Users/jinwoohong/orca/workspaces/orca/osprey. - The main app did not expose CDP, so browser-level renderer profiling was limited.
- The harness creates a throwaway bash terminal, switches away and back, sends a marker command, waits for a receipt file, and closes the throwaway terminal.
- Harness path:
.tmp/terminal-main-app-typing-lag/probe.mjs - The strongest repro switches through an old, output-rich workspace before typing in the probe terminal.
- Current deterministic alternate terminal:
term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408in thetriage-issuesworkspace.
Useful harness modes:
ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=terminal-send ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs
ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs
ORCA_PROBE_RUNS=8 ORCA_PROBE_SKIP_SWITCH=1 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs
ORCA_PROBE_RUNS=4 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_SKIP_FOCUS=1 ORCA_PROBE_SAMPLE_RENDERER=0 ORCA_PROBE_ALT_TERMINAL=term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs
Key Measurements
2026-07-01 follow-up: cold visit vs warm revisit
After the metadata-only listSessions() fix, the user reported a sharper
pattern:
- First visit to a terminal, where the terminal briefly shows blank while the renderer/webgl surface loads, does not show the typing lag.
- Revisiting that same already-mounted terminal brings the lag back.
Code-level reproduction:
connectPanePtypreviously armed a one-shot input liveness check fromnoteVisibilityResume().- The first
xterm.onDatainput after a warm visibility resume calledwindow.api.pty.listSessions()before forwarding the byte withtransport.sendInput(data). - A focused unit regression captured the bad order as
["listSessions", "sendInput"].
Fix direction:
- Terminal input no longer starts a renderer→main→daemon session enumeration.
- Hidden→visible lifecycle reconciliation and daemon missing-session exit events remain responsible for stale pane cleanup.
- The focused regression now asserts that repeated warm resumes plus typing
produce zero
listSessions()calls from the input handler.
2026-07-01 follow-up: preserved old daemon after input fix
After the input-handler fix, the main packaged profile still reproduced the warm-switch lag because the live v18 daemon was preserved from an older build:
- Fresh main-profile repro:
.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-08-111Z.json - Direct daemon writes after switching took 661, 982, 998, 980, and 1000 ms.
- Receipt latency after the daemon write returned stayed low at 77-85 ms.
- No-switch control:
.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-28-346Z.jsonended with fast direct daemon writes once the probe terminal settled.
The remaining source hot path was the visibility-resume dead-session sweep:
- Switching a warm terminal hidden→visible ran the lifecycle visibility effect.
- The effect scheduled
reconcileDeadSessions. reconcileDeadSessionsinvokedwindow.api.pty.listSessions().- A preserved old daemon still implemented
listSessionsby snapshotting every live session, so the user's next daemonwritequeued behind that work.
Fix:
- Replace the automatic visibility-resume
listSessions()sweep with a targeted single-session liveness check. - Main exposes
pty:hasPty(id), which reads provider-owned in-memory PTY state and returnsnullwhen the provider cannot answer authoritatively. - Renderer visible-resume asks only about each mounted pane's current PTY id.
The pane tears down only on an authoritative
false;true,null, rejected checks, remote-runtime ids, SSH ids, and stale/newborn races all fail open. - Keep visibility resume process tracking and PTY-size reassertion intact.
- This preserves the recovery added by
a9ef6f916for panes that missedpty:exitwhile hidden, without putting a daemon-wide session enumeration on the warm-switch/input path.
Verification:
- Focused vitest suite:
508tests passed across terminal lifecycle, pty-connection, dead-session reconcile, and PTY IPC. - Headful fullscreen E2E harness:
tests/e2e/terminal-warm-switch-no-list-sessions.tmp.spec.tswraps main-processpty:listSessionswith an 800 ms stall and then switches warm workspaces and types into the terminal. - Latest E2E artifact:
.tmp/terminal-warm-switch-no-list-sessions/result-1782929853828.jsonshowedfullscreen: true,listSessionCallCount: 0, andpostTypeEchoLatencyMs: 10.
CLI terminal-send, switch away/back
Result file: .tmp/terminal-main-app-typing-lag/result-2026-07-01T06-59-45-098Z.json
- Average echo latency was about 1841 ms.
- Receipt latency was roughly 836-1445 ms.
CLI terminal-send, no switch
- Average echo latency was about 312 ms.
CLI terminal-send, switch away/back, 1000 ms settle before send
- Average echo latency was about 574 ms.
- Receipt latency was mostly 1-227 ms, with one run around 684 ms.
- This suggests the problematic window is bounded and concentrated immediately after switch/resume.
Direct daemon request, switch away/back
Result file: .tmp/terminal-main-app-typing-lag/result-2026-07-01T07-04-43-497Z.json
- The harness opened its own daemon control socket and sent the real daemon
writerequest directly. - Daemon write response time was roughly 947-1142 ms.
- Receipt latency after the daemon write response was about 76 ms.
- Average echo latency was about 1385 ms.
Direct daemon request, no switch
Result file: .tmp/terminal-main-app-typing-lag/result-2026-07-01T07-05-12-496Z.json
- Daemon write response time was 0-45 ms.
- Receipt latency was roughly 76-85 ms.
- Average echo latency was about 258 ms.
Direct daemon request, output-rich workspace switch
Result file: .tmp/terminal-main-app-typing-lag/result-2026-07-01T07-22-06-789Z.json
- The alternate terminal was
term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408in thetriage-issuesworkspace. - All four runs delayed.
- Direct daemon
writeresponse times were 1566, 1527, 1477, and 1333 ms. - Average echo latency was about 1790 ms.
- A daemon CPU sample was captured at
.tmp/terminal-main-app-typing-lag/daemon-71111-sample-20260701032206.txt.
Latest reproduced run:
.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-25-513Z.json
- Two runs after switching through
triage-issues. - Direct daemon
writeresponse times were 1556 and 1582 ms. - Receipt latency after the daemon write response was about 76-77 ms.
- This confirms the daemon request itself waits; once it returns, the PTY and shell process the input quickly.
Direct daemon ping loop during switch
A direct daemon client sent ping requests every roughly 50 ms while the harness issued orca terminal switch away and back.
- Pings before and after the switch were effectively immediate.
- One ping sent around 469 ms after switch start waited 1212 ms.
- No terminal input was involved in this probe.
This is the strongest evidence so far that workspace/terminal switching creates a daemon event-loop stall. The typing lag is a user-visible symptom of the same stall, not the root trigger.
Direct daemon request, no switch
Latest no-switch result:
.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-58-622Z.json
- First run had a direct daemon
writeresponse of 571 ms, likely a new-probe/settling artifact. - Next two runs were 0-1 ms.
- No-switch is generally fast once the probe terminal is settled.
Pause after switching away
- Adding
ORCA_PROBE_AFTER_SWITCH_AWAY_MS=1500before switching back made direct daemon writes fast again. - This indicates expensive work starts when Orca switches into or resumes the output-rich workspace, then spills into the immediate switch-back/write window.
Latest pause-control result:
.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-29-58-568Z.json
- Direct daemon
writeresponse times were 0, 0, and 0 ms. - Echo latency was about 343-363 ms.
- This bounds the problematic window to roughly the first 1-1.5 seconds after switching into/through the expensive workspace.
Synthetic heavy terminals
- Synthetic split panes with large scrollback were not enough to reproduce reliably.
- A plain heavy scrollback split reproduced once in four runs, then larger synthetic scrollback stayed fast.
- A synthetic TUI repaint split stayed fast.
- The issue is therefore not simply "large output" or "any hidden repaint"; old retained/reattach/snapshot state is still suspect.
Output timestamp check
lastOutputAtdid not advance when switching through thetriage-issuesterminal and away.- That weakens the theory that a resume-triggered SIGWINCH caused the child TUI to emit a fresh repaint burst.
- The delay can happen without fresh PTY output from the child process.
Snapshot and resize controls
Direct getSnapshot probes on real sessions were much cheaper than the observed lag:
- Output-rich
triageterminal: about 10 ms, roughly 122 KB response. - Active
ospreyCodex terminal: about 57 ms, roughly 804 KB response.
Synthetic resize pulses on throwaway heavy-scrollback terminals were also cheap:
- Background 80x24 resize pulse: about 2 ms.
- Focused 232x86 resize pulse: about 2 ms.
These controls weaken the idea that one ordinary snapshot or one ordinary resize explains a 1.2-1.6 second stall. The remaining suspicious shape is switch-time fanout: many warm reattachments, snapshots, visibility/resume requests, pending-output drains, or checkpoint-like work running serially in the daemon.
Daemon listSessions proof
The daemon-only measurement identified the blocking request:
[
{ "type": "ping", "elapsedMs": 0 },
{ "type": "listSessions", "elapsedMs": 552, "count": 137 },
{ "type": "ping", "elapsedMs": 0 },
{ "type": "listSessions", "elapsedMs": 547, "count": 137 },
{ "type": "ping", "elapsedMs": 0 }
]
A second daemon-only queue test sent two listSessions requests and then a ping from another client:
{
"totalMs": 1069,
"listSessions1Ms": 1068,
"listSessions2Ms": 1068,
"pingBehindListSessionsMs": 1034,
"sessions": 137
}
This reproduces the same stall shape without terminal input or UI switching: a control request sent behind resume-time listSessions waits about one second.
Source cause:
TerminalHost.listSessions()loops every live daemon session.- For each session, it calls
session.getSnapshot()only to readcolsandrows. getSnapshot()serializes the headless xterm buffer, so a liveness/session-list request scales with terminal scrollback/state across the whole profile.- Renderer visibility resume calls
window.api.pty.listSessions()for dead-session reconciliation. With about 137 live daemon sessions, one resume-time list was about 550 ms; two back-to-back resumes were about 1.0-1.1 seconds.
What This Rules Out
- It is probably not keyboard focus. Direct daemon writes reproduce the delay.
- It is probably not only renderer paint. Direct daemon writes wait before the shell receives bytes.
- It is probably not bash or node-pty readiness. No-switch direct writes are fast, and receipt latency after a delayed daemon response is low.
- It is probably not queueing only on Orca's normal daemon client socket. The direct-daemon harness uses a separate socket and still sees the delay.
- It is not caused by typing itself. A ping loop with no write showed the daemon stall during switching.
- It is unlikely to be a single normal
getSnapshotorresizecall, because direct controls for those operations are much cheaper than the observed stall. - The resume-time dead-pane recovery is still necessary; the hot path should use single-PTY liveness, not a global session list.
Current Conclusion
Switching workspaces or terminals can create a short daemon/main busy window
when warm resume triggers global session enumeration. During that window, even a
direct terminal write request waits before the daemon services it. Once the
daemon services the write, the shell receives and processes the bytes quickly.
The confirmed root for the remaining warm-switch lag is the visibility-resume
dead-session reconciliation path calling global listSessions() in profiles
with many preserved daemon sessions. The original dead-session recovery is valid;
the expensive primitive was the problem.
Leading Hypotheses
- Confirmed: resume-time dead-session reconciliation called daemon
listSessions, and older daemons synchronously snapshot every live session to return cols/rows. - Confirmed: avoiding global
listSessions()on warm resume removes the request that queued ahead of first post-switch input. - Possible secondary contributor: switching to certain old or output-rich
workspaces may also reattach existing daemon-backed PTYs. The daemon
createOrAttachpath synchronously callsexisting.getSnapshot()before responding. - Possible secondary contributor: hidden-output recovery, pending-output draining, or another snapshot-like serialization path runs synchronously on resume and delays request handling.
- Less likely: workspace or terminal resume triggers visible-terminal
resize/SIGWINCH or TUI repaint output. The unchanged
lastOutputAtobservation currently makes this less likely thanlistSessions.
Relevant Code Areas
- CLI terminal send:
src/cli/handlers/terminal.ts - Runtime terminal send/focus/write:
src/main/runtime/orca-runtime.ts - Runtime PTY data handling:
onPtyData,trackHeadlessTerminalData, hidden-output serialization insrc/main/runtime/orca-runtime.ts - Daemon request routing:
src/main/daemon/daemon-server.ts - Daemon session write/resize/output handling:
src/main/daemon/session.ts - Daemon-side headless terminal state:
src/main/daemon/headless-emulator.ts - Daemon adapter:
src/main/daemon/daemon-pty-adapter.ts - Main IPC PTY controller:
src/main/ipc/pty.ts - Renderer terminal resume:
src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts - Renderer PTY connection and resume size reassertion:
src/renderer/src/components/terminal-pane/pty-connection.ts - Renderer output scheduler:
src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts - Existing-session reattach snapshot:
TerminalHost.createOrAttachinsrc/main/daemon/terminal-host.ts - Session snapshot serialization:
TerminalSession.getSnapshot()insrc/main/daemon/session.ts - Snapshot cost benchmark:
src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts
Current Root-Cause Theory
The root cause is using global session enumeration as a per-pane liveness check:
- Workspace switching makes a terminal pane visible again.
- The renderer schedules dead-session reconciliation on hidden-to-visible resume.
- The old reconcile path called
window.api.pty.listSessions(), which reaches the daemonlistSessionsRPC. - Older preserved daemons implement
TerminalHost.listSessions()by callingsession.getSnapshot()for every live session. - With a heavy profile, those snapshot serializations block the daemon event loop for about 550 ms per list.
- A switch away/back can put a
writebehind two resume-time lists, yielding about 1.0-1.6 seconds of delayed input.
This theory fits the current evidence:
- Direct daemon writes block, so the delay is below keyboard focus and normal CLI plumbing.
- Receipt handling after the daemon write response is fast, so the shell and PTY are not the bottleneck.
- No-switch writes are fast.
- Waiting after switching away lets the expensive resume work finish, so switching back and typing is fast.
lastOutputAtdoes not advance, so the child process probably is not generating the expensive work.- Synthetic fresh heavy output did not reproduce reliably, which points toward retained old session/state, request fanout, or workspace-specific resume behavior rather than simple line count.
The correct fix is two-layered:
- Keep daemon
listSessionsmetadata-only for builds where callers genuinely need the global session list. - Do not use
listSessionsfor warm-resume dead-pane recovery. Usepty:hasPty(id)to ask about the pane's own PTY id, backed by provider in-memory state. Close only on authoritativefalse; fail open ontrue,null, unsupported providers, remote-runtime/SSH ids, and stale/newborn races.
Investigation Constraints
- Do not kill or restart the user's main packaged Orca app or daemon without explicit approval.
- Use throwaway terminals for probes and close them afterward.
- Keep temp harnesses and screenshots out of commits unless explicitly requested.
- Reproduce in the real main-app flow when possible; lighter dev profiles may not show the problem.
Verification Plan
- Unit-test that
pty:hasPty(id)does not call providerlistProcesses(). - Unit-test that targeted liveness still closes a missing local PTY and fails open for live/unknown/stale cases.
- Unit-test that terminal input and warm resume do not call
listSessions(). - Re-run the headful fullscreen warm-switch E2E with
pty:listSessionsartificially delayed and assert the count stays zero. - Re-run the main-app direct-daemon switch/no-switch harness when validating against the user's heavy profile.