Fix terminal switch input lag from daemon session listing (#7002)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-07-01 13:19:50 -07:00
committed by GitHub
co-authored by Orca
parent e0a7f0eadd
commit 8afa84af82
14 changed files with 786 additions and 203 deletions
@@ -0,0 +1,362 @@
# 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-7ec12cbfa408` in the `triage-issues` workspace.
Useful harness modes:
```sh
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:
- `connectPanePty` previously armed a one-shot input liveness check from
`noteVisibilityResume()`.
- The first `xterm.onData` input after a warm visibility resume called
`window.api.pty.listSessions()` before forwarding the byte with
`transport.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.json`
ended with fast direct daemon writes once the probe terminal settled.
The remaining source hot path was the visibility-resume dead-session sweep:
1. Switching a warm terminal hidden→visible ran the lifecycle visibility effect.
2. The effect scheduled `reconcileDeadSessions`.
3. `reconcileDeadSessions` invoked `window.api.pty.listSessions()`.
4. A preserved old daemon still implemented `listSessions` by snapshotting every
live session, so the user's next daemon `write` queued 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 returns `null` when 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 `a9ef6f916` for panes that missed
`pty:exit` while hidden, without putting a daemon-wide session enumeration on
the warm-switch/input path.
Verification:
- Focused vitest suite: `508` tests 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.ts`
wraps main-process `pty:listSessions` with 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.json`
showed `fullscreen: true`, `listSessionCallCount: 0`, and
`postTypeEchoLatencyMs: 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 `write` request 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-7ec12cbfa408` in the `triage-issues` workspace.
- All four runs delayed.
- Direct daemon `write` response 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 `write` response 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 `write` response 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=1500` before 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 `write` response 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
- `lastOutputAt` did not advance when switching through the `triage-issues` terminal 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 `triage` terminal: about 10 ms, roughly 122 KB response.
- Active `osprey` Codex 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:
```json
[
{ "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:
```json
{
"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 read `cols` and `rows`.
- `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 `getSnapshot` or `resize` call, 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
1. Confirmed: resume-time dead-session reconciliation called daemon
`listSessions`, and older daemons synchronously snapshot every live session
to return cols/rows.
2. Confirmed: avoiding global `listSessions()` on warm resume removes the
request that queued ahead of first post-switch input.
3. Possible secondary contributor: switching to certain old or output-rich
workspaces may also reattach existing daemon-backed PTYs. The daemon
`createOrAttach` path synchronously calls `existing.getSnapshot()` before
responding.
4. Possible secondary contributor: hidden-output recovery, pending-output
draining, or another snapshot-like serialization path runs synchronously on
resume and delays request handling.
5. Less likely: workspace or terminal resume triggers visible-terminal
resize/SIGWINCH or TUI repaint output. The unchanged `lastOutputAt`
observation currently makes this less likely than `listSessions`.
## 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 in `src/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.createOrAttach` in `src/main/daemon/terminal-host.ts`
- Session snapshot serialization: `TerminalSession.getSnapshot()` in `src/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:
1. Workspace switching makes a terminal pane visible again.
2. The renderer schedules dead-session reconciliation on hidden-to-visible resume.
3. The old reconcile path called `window.api.pty.listSessions()`, which reaches
the daemon `listSessions` RPC.
4. Older preserved daemons implement `TerminalHost.listSessions()` by calling
`session.getSnapshot()` for every live session.
5. With a heavy profile, those snapshot serializations block the daemon event
loop for about 550 ms per list.
6. A switch away/back can put a `write` behind 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.
- `lastOutputAt` does 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:
1. Keep daemon `listSessions` metadata-only for builds where callers genuinely
need the global session list.
2. Do not use `listSessions` for warm-resume dead-pane recovery. Use
`pty:hasPty(id)` to ask about the pane's own PTY id, backed by provider
in-memory state. Close only on authoritative `false`; fail open on `true`,
`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
1. Unit-test that `pty:hasPty(id)` does not call provider `listProcesses()`.
2. Unit-test that targeted liveness still closes a missing local PTY and fails
open for live/unknown/stale cases.
3. Unit-test that terminal input and warm resume do not call `listSessions()`.
4. Re-run the headful fullscreen warm-switch E2E with `pty:listSessions`
artificially delayed and assert the count stays zero.
5. Re-run the main-app direct-daemon switch/no-switch harness when validating
against the user's heavy profile.
+20 -1
View File
@@ -1,7 +1,7 @@
/* oxlint-disable max-lines */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Session, type SubprocessHandle } from './session'
import { TerminalHost } from './terminal-host'
import type { SubprocessHandle } from './session'
function createMockSubprocess(
options: { startupCommandDeliveredInShellArgs?: boolean } = {}
@@ -351,6 +351,25 @@ describe('TerminalHost', () => {
expect(sessions).toHaveLength(2)
expect(sessions.map((s) => s.sessionId).sort()).toEqual(['session-1', 'session-2'])
})
it('uses applied size without serializing terminal snapshots', async () => {
await host.createOrAttach({
sessionId: 'session-1',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
host.resize('session-1', 132, 43)
const getSnapshot = vi.spyOn(Session.prototype, 'getSnapshot')
expect(host.listSessions()[0]).toMatchObject({
sessionId: 'session-1',
cols: 132,
rows: 43
})
expect(getSnapshot).not.toHaveBeenCalled()
})
})
describe('detach', () => {
+3 -3
View File
@@ -295,7 +295,7 @@ export class TerminalHost {
if (!session.isAlive) {
continue
}
const snapshot = session.getSnapshot()
const size = session.getAppliedSize()
result.push({
sessionId: session.sessionId,
state: session.state,
@@ -303,8 +303,8 @@ export class TerminalHost {
isAlive: true,
pid: session.pid,
cwd: session.getCwd(),
cols: snapshot?.cols ?? 0,
rows: snapshot?.rows ?? 0,
cols: size?.cols ?? 0,
rows: size?.rows ?? 0,
createdAt: 0
})
}
+95
View File
@@ -3140,6 +3140,101 @@ describe('registerPtyHandlers', () => {
})
})
it('checks single-PTY liveness without listing every session', async () => {
const hasPty = vi.fn((id: string) => id === 'live-pty')
const listProcesses = vi.fn(async () => {
throw new Error('listProcesses should not be called')
})
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses,
attach: vi.fn(),
hasPty,
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(mainWindow as never)
await expect(handlers.get('pty:hasPty')!(null, { id: 'live-pty' })).resolves.toBe(true)
await expect(handlers.get('pty:hasPty')!(null, { id: 'dead-pty' })).resolves.toBe(false)
expect(hasPty).toHaveBeenCalledWith('live-pty')
expect(hasPty).toHaveBeenCalledWith('dead-pty')
expect(listProcesses).not.toHaveBeenCalled()
})
it('treats unsupported or failed single-PTY liveness as unknown', async () => {
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
registerPtyHandlers(mainWindow as never)
await expect(handlers.get('pty:hasPty')!(null, { id: 'maybe-pty' })).resolves.toBe(null)
const hasPty = vi.fn(() => {
throw new Error('provider unavailable')
})
setLocalPtyProvider({
spawn: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(async () => []),
attach: vi.fn(),
hasPty,
getDefaultShell: vi.fn(),
getProfiles: vi.fn()
} as never)
await expect(handlers.get('pty:hasPty')!(null, { id: 'maybe-pty' })).resolves.toBe(null)
})
it('lists duplicate SSH relay session ids as distinct app sessions', async () => {
registerPtyHandlers(mainWindow as never)
const shutdownA = vi.fn(async () => undefined)
+18
View File
@@ -1263,6 +1263,7 @@ export function registerPtyHandlers(
ipcMain.removeHandler('pty:spawn')
ipcMain.removeHandler('pty:kill')
ipcMain.removeHandler('pty:listSessions')
ipcMain.removeHandler('pty:hasPty')
ipcMain.removeHandler('pty:hasChildProcesses')
ipcMain.removeHandler('pty:getForegroundProcess')
ipcMain.removeHandler('pty:getCwd')
@@ -3445,6 +3446,23 @@ export function registerPtyHandlers(
}
)
ipcMain.handle('pty:hasPty', async (_event, args: { id: string }): Promise<boolean | null> => {
const ownedConnectionId = ptyOwnership.get(args.id)
const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null
const provider = parsedSshId
? sshProviders.get(parsedSshId.connectionId)
: tryGetProviderForPty(args.id)
if (!provider?.hasPty) {
return null
}
try {
return provider.hasPty(args.id)
} catch {
// Why: liveness is only allowed to close panes on an authoritative false.
return null
}
})
ipcMain.handle(
'pty:hasChildProcesses',
async (_event, args: { id: string }): Promise<boolean> => {
+1
View File
@@ -1158,6 +1158,7 @@ export type PreloadApi = {
getCwd: (id: string) => Promise<string>
getSize: (id: string) => Promise<{ cols: number; rows: number } | null>
listSessions: () => Promise<{ id: string; cwd: string; title: string }[]>
hasPty: (id: string) => Promise<boolean | null>
getMainBufferSnapshot: (
id: string,
opts?: { scrollbackRows?: number }
+1
View File
@@ -830,6 +830,7 @@ const api = {
listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> =>
ipcRenderer.invoke('pty:listSessions'),
hasPty: (id: string): Promise<boolean | null> => ipcRenderer.invoke('pty:hasPty', { id }),
getMainBufferSnapshot: (
id: string,
@@ -666,6 +666,7 @@ describe('connectPanePty', () => {
pty: {
signal: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
hasPty: vi.fn().mockResolvedValue(true),
getSize: vi.fn().mockResolvedValue(null),
reportGeometry: vi.fn(),
getMainBufferSnapshot: vi.fn().mockResolvedValue(null),
@@ -11428,6 +11429,83 @@ describe('connectPanePty', () => {
expect(manager.closePane).toHaveBeenCalledWith(2)
})
it('closes a split pane when targeted liveness says its local session is missing', async () => {
const { connectPanePty } = await import('./pty-connection')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
const transport = createMockTransport('pty-pane-2')
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-pane-2'
}
)
transportFactoryQueue.push(transport)
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const hasPty = vi.fn(async () => false)
const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
capturedDataCallback.current?.('shell prompt')
binding.reconcileIfSessionMissing(hasPty)
await flushAsyncTicks()
expect(hasPty).toHaveBeenCalledWith('pty-pane-2')
expect(manager.closePane).toHaveBeenCalledWith(2)
})
it('does not close when targeted liveness is live or unknown', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-pane-2')
transportFactoryQueue.push(transport)
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
binding.reconcileIfSessionMissing(vi.fn(async () => true))
binding.reconcileIfSessionMissing(vi.fn(async () => null))
await flushAsyncTicks()
expect(manager.closePane).not.toHaveBeenCalled()
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
})
it('does not apply a stale targeted liveness result after reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
let resolveHasPty: (value: boolean) => void = () => {
throw new Error('hasPty promise resolver was not initialized')
}
const hasPty = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveHasPty = resolve
})
)
const transport = createMockTransport('pty-pane-2')
transportFactoryQueue.push(transport)
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
binding.reconcileIfSessionMissing(hasPty)
transport.getPtyId.mockReturnValue('pty-pane-2-reattached')
resolveHasPty(false)
await flushAsyncTicks()
expect(manager.closePane).not.toHaveBeenCalled()
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
})
it('does NOT tear down a newborn pane when the snapshot was requested before it bound', async () => {
// Why (regression): a snapshot requested before the spawn bound cannot
// prove the fresh ptyId dead. Drives the REAL reconcile body to prove the
@@ -11728,13 +11806,13 @@ describe('connectPanePty', () => {
})
})
describe('input liveness re-check IPC gating (perf)', () => {
describe('terminal input liveness IPC gating (perf)', () => {
// Why (perf regression guard): listSessions() is a renderer→main→daemon
// round-trip. The input-driven liveness re-check must fire at most once per
// resume window, never once per keystroke, or every healthy local pane puts
// a process-enumeration round-trip on the typing hot path.
// round-trip over every live session. Terminal input must never start that
// enumeration; visibility reconcile and daemon exit events own liveness.
async function connectActivePaneWithInput(): Promise<{
binding: { noteVisibilityResume: () => void }
transport: MockTransport
typeKeystroke: (data?: string) => void
}> {
const { connectPanePty } = await import('./pty-connection')
@@ -11751,6 +11829,7 @@ describe('connectPanePty', () => {
}
return {
binding,
transport,
// Drives the real xterm onData (terminal input) handler.
typeKeystroke: (data = 'a') => sendTerminalInputThroughPane(pane, data)
}
@@ -11768,7 +11847,7 @@ describe('connectPanePty', () => {
expect(listSessions).not.toHaveBeenCalled()
})
it('fires listSessions once for the first input after a visibility resume', async () => {
it('does not fire listSessions for input after a visibility resume', async () => {
const listSessions = vi.mocked(window.api.pty.listSessions)
listSessions.mockClear()
const { binding, typeKeystroke } = await connectActivePaneWithInput()
@@ -11777,73 +11856,42 @@ describe('connectPanePty', () => {
typeKeystroke('a')
typeKeystroke('b')
expect(listSessions).toHaveBeenCalledTimes(1)
})
it('re-arms one re-check after a second visibility resume', async () => {
const listSessions = vi.mocked(window.api.pty.listSessions)
listSessions.mockClear()
const { binding, typeKeystroke } = await connectActivePaneWithInput()
binding.noteVisibilityResume()
typeKeystroke('a')
typeKeystroke('b')
expect(listSessions).toHaveBeenCalledTimes(1)
binding.noteVisibilityResume()
typeKeystroke('c')
typeKeystroke('d')
expect(listSessions).toHaveBeenCalledTimes(2)
})
it('never fires listSessions for a remote: web-runtime pane (liveness owned by host snapshot)', async () => {
const listSessions = vi.mocked(window.api.pty.listSessions)
listSessions.mockClear()
const { connectPanePty } = await import('./pty-connection')
// Remote panes report a null connectionId but a remote:-prefixed ptyId, so
// the SSH/connectionId guard alone would not exclude them — the remote
// prefix guard must.
const transport = createMockTransport('remote:env-1@@terminal-2')
transport.getConnectionId.mockReturnValue(null)
transportFactoryQueue.push(transport)
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const pane = createPane(2)
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
noteVisibilityResume: () => void
}
binding.noteVisibilityResume()
sendTerminalInputThroughPane(pane, 'x')
sendTerminalInputThroughPane(pane, 'y')
expect(listSessions).not.toHaveBeenCalled()
})
it('never fires listSessions for an SSH pane after resume', async () => {
it('sends the first post-resume input without starting the liveness re-check', async () => {
const listSessions = vi.mocked(window.api.pty.listSessions)
listSessions.mockClear()
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('ssh-pty-2')
transport.getConnectionId.mockReturnValue('ssh-connection-1')
transportFactoryQueue.push(transport)
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
const calls: string[] = []
listSessions.mockImplementation(() => {
calls.push('listSessions')
return Promise.resolve([])
})
const { binding, transport, typeKeystroke } = await connectActivePaneWithInput()
transport.sendInput.mockImplementation(() => {
calls.push('sendInput')
return true
})
const pane = createPane(2)
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
noteVisibilityResume: () => void
}
binding.noteVisibilityResume()
sendTerminalInputThroughPane(pane, 'x')
sendTerminalInputThroughPane(pane, 'y')
typeKeystroke('a')
expect(calls).toEqual(['sendInput'])
})
it('does not re-arm input-driven listSessions across repeated visibility resumes', async () => {
const listSessions = vi.mocked(window.api.pty.listSessions)
listSessions.mockClear()
const { binding, typeKeystroke } = await connectActivePaneWithInput()
binding.noteVisibilityResume()
typeKeystroke('a')
typeKeystroke('b')
expect(listSessions).not.toHaveBeenCalled()
binding.noteVisibilityResume()
typeKeystroke('c')
typeKeystroke('d')
expect(listSessions).not.toHaveBeenCalled()
})
})
@@ -23,7 +23,11 @@ import {
hasCachedWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
import { shouldReconcileDeadSession } from './terminal-dead-session-reconcile'
import {
shouldReconcileDeadSession,
shouldReconcileMissingSession,
type HasPty
} from './terminal-dead-session-reconcile'
import type { PtyConnectionDeps } from './pty-connection-types'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer'
@@ -542,6 +546,7 @@ type PanePtyBinding = IDisposable & {
syncProcessTracking: () => void
noteVisibilityResume: () => void
reconcileIfSessionDead: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void
}
function isAgentTaskCompleteNotificationEnabled(): boolean {
@@ -1449,7 +1454,7 @@ export function connectPanePty(
pane.container.dataset.ptyId = ptyId
}
let activePanePtyBinding: string | null = null
// Why: bind time so reconcile can ignore a listSessions snapshot requested
// Why: bind time lets async liveness reconcile ignore a request started
// before this PTY bound (newborn race). Null disables the guard (fail-safe).
let activePanePtyBindingBoundAt: number | null = null
const clearPanePtyFitBinding = (): void => {
@@ -2293,11 +2298,6 @@ export function connectPanePty(
clearPendingTerminalInputIntent()
return
}
// Why (Defect #2): a keystroke against a pane whose daemon session was
// reaped while hidden is silently dropped — sendInput still returns true.
// Kick off a fire-and-forget liveness re-check so the dead pane is cleaned
// up without relying on (the never-occurring) sendInput false return.
recheckLivenessAfterInput()
const intent = pendingTerminalInputIntent
// Why: real xterm can deliver the terminal byte even when our DOM keydown
// listener missed the press. Exact Ctrl+C/Escape bytes are still safe to
@@ -5083,49 +5083,49 @@ export function connectPanePty(
onExit(currentPtyId)
}
// Why (perf + startup correctness): listSessions() is authoritative only
// after a real visibility resume. Fresh PTY startup can briefly lag the daemon
// listing, so newborn terminals start disarmed and noteVisibilityResume grants
// exactly one first-input liveness probe for the next resume window.
let livenessRecheckArmedForResume = false
// Why (Defect #2 defense-in-depth): in the broken state sendInput returns
// true (connected/ptyId still set) so the dropped keystroke is invisible to
// the renderer. A fire-and-forget liveness re-check on the FIRST input after
// a resume cleans the pane up promptly instead of waiting for the resume
// pass alone. It REDUCES but cannot eliminate the first-keystroke drop (that
// byte is already gone daemon-side).
const recheckLivenessAfterInput = (): void => {
if (disposed || !livenessRecheckArmedForResume) {
return
}
// Why: consume the resume token before inspecting provider details so SSH,
// remote-runtime, and concurrent keystrokes cannot retry this hot-path check
// until the lifecycle reports another true hidden-to-visible resume.
livenessRecheckArmedForResume = false
const currentPtyId = transport.getPtyId()
const currentConnectionId = transport.getConnectionId?.()
const reconcileIfSessionMissing = (
hasPty: HasPty,
livenessRequestedAt = performance.now()
): void => {
const requestedPtyId = transport.getPtyId()
if (
!currentPtyId ||
// Why: this ptyId's exit was already handled — nothing left to reconcile.
handledExitPtyId === currentPtyId ||
// Why: `remote:` web-runtime liveness is owned by the host snapshot, not
// listSessions; skip here so a remote pane's keystrokes never put a local
// daemon round-trip on the typing hot path (reconcile would no-op anyway).
isRemoteRuntimePtyId(currentPtyId) ||
(currentConnectionId !== null && currentConnectionId !== undefined)
!requestedPtyId ||
requestedPtyId === handledExitPtyId ||
requestedPtyId.startsWith(REMOTE_PTY_ID_PREFIX) ||
transport.getConnectionId?.() != null
) {
return
}
// Why: capture request time before the round-trip so a pane that bound after
// this request is not torn down by its (pre-bind) stale snapshot.
const requestedAt = performance.now()
void window.api.pty
.listSessions()
.then((sessions) => {
reconcileIfSessionDead(new Set(sessions.map((session) => session.id)), requestedAt)
let livenessPromise: Promise<boolean | null>
try {
livenessPromise = Promise.resolve(hasPty(requestedPtyId))
} catch {
return
}
void livenessPromise
.then((isLive) => {
if (disposed) {
return
}
const currentPtyId = transport.getPtyId()
if (
!currentPtyId ||
currentPtyId !== requestedPtyId ||
handledExitPtyId === currentPtyId ||
!shouldReconcileMissingSession({
ptyId: currentPtyId,
connectionId: transport.getConnectionId?.(),
isLive,
ptyBoundAt: activePanePtyBindingBoundAt,
livenessRequestedAt
})
) {
return
}
onExit(currentPtyId)
})
// Why: a rejected listing is "unknown" — never close a pane on it.
.catch(() => {})
}
@@ -5133,17 +5133,14 @@ export function connectPanePty(
syncProcessTracking() {
agentCompletionCoordinator.startProcessTracking()
},
// Why: re-arm the once-per-resume input re-check when the pane becomes
// visible again. Called from the lifecycle visibility effect; the gate
// keeps the typing hot path off the listSessions IPC between resumes.
// Why: called from the lifecycle visibility effect so the visible-resume
// size readback can repair dropped hidden resizes without refitting against
// xterm's transient hidden DOM fallback.
noteVisibilityResume() {
livenessRecheckArmedForResume = true
// Why: the visibility-resume path reattaches WebGL before doing the
// authoritative fit. Fitting here can measure xterm's hidden DOM fallback
// and send a transient narrow SIGWINCH before the renderer is restored.
ptySizeReassertion.request({ fit: false })
},
reconcileIfSessionDead,
reconcileIfSessionMissing,
dispose() {
disposed = true
// Why: the post-spawn reconcile polls across frames; cancel its pending
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import {
reconcileDeadSessions,
reconcileMissingSessions,
shouldReconcileMissingSession,
shouldReconcileDeadSession
} from './terminal-dead-session-reconcile'
@@ -128,6 +130,80 @@ describe('shouldReconcileDeadSession', () => {
})
})
describe('shouldReconcileMissingSession', () => {
it('reconciles only an authoritative missing local PTY', () => {
expect(
shouldReconcileMissingSession({
ptyId: 'wt@@dead',
connectionId: null,
isLive: false
})
).toBe(true)
expect(
shouldReconcileMissingSession({
ptyId: 'wt@@alive',
connectionId: null,
isLive: true
})
).toBe(false)
expect(
shouldReconcileMissingSession({
ptyId: 'wt@@unknown',
connectionId: null,
isLive: null
})
).toBe(false)
})
it('keeps the remote, SSH, and newborn guards from the broad reconcile path', () => {
expect(
shouldReconcileMissingSession({
ptyId: 'remote:env-1:abc',
connectionId: null,
isLive: false
})
).toBe(false)
expect(
shouldReconcileMissingSession({
ptyId: 'wt@@ssh-dead',
connectionId: 'ssh-target-1',
isLive: false
})
).toBe(false)
expect(
shouldReconcileMissingSession({
ptyId: 'wt@@newborn',
connectionId: null,
isLive: false,
ptyBoundAt: 1000,
livenessRequestedAt: 900
})
).toBe(false)
})
})
describe('reconcileMissingSessions', () => {
it('invokes each binding with the targeted liveness probe and request timestamp', () => {
const hasPty = vi.fn(async () => true)
const bindingA = { reconcileIfSessionMissing: vi.fn() }
const bindingB = { reconcileIfSessionMissing: vi.fn() }
const before = performance.now()
reconcileMissingSessions({ bindings: [bindingA, bindingB], hasPty })
const after = performance.now()
expect(bindingA.reconcileIfSessionMissing).toHaveBeenCalledWith(hasPty, expect.any(Number))
expect(bindingB.reconcileIfSessionMissing).toHaveBeenCalledWith(hasPty, expect.any(Number))
const [, requestedAt] = bindingA.reconcileIfSessionMissing.mock.calls[0]!
expect(requestedAt).toBeGreaterThanOrEqual(before)
expect(requestedAt).toBeLessThanOrEqual(after)
})
})
describe('reconcileDeadSessions', () => {
function createBinding() {
return {
@@ -13,8 +13,11 @@ const REMOTE_PTY_ID_PREFIX = 'remote:'
*/
export type ReconcilableBinding = {
reconcileIfSessionDead?: (liveSessionIds: Set<string>, snapshotRequestedAt?: number) => void
reconcileIfSessionMissing?: (hasPty: HasPty, livenessRequestedAt?: number) => void
}
export type HasPty = (ptyId: string) => Promise<boolean | null>
/**
* PURE decision: should the pane bound to `ptyId` be reconciled (torn down)
* given the resolved set of live session ids?
@@ -60,6 +63,37 @@ export function shouldReconcileDeadSession(args: {
return !liveSessionIds.has(ptyId)
}
export function shouldReconcileMissingSession(args: {
ptyId: string | null | undefined
connectionId: string | null | undefined
isLive: boolean | null | undefined
ptyBoundAt?: number | null
livenessRequestedAt?: number | null
}): boolean {
if (args.isLive !== false) {
return false
}
return shouldReconcileDeadSession({
ptyId: args.ptyId,
connectionId: args.connectionId,
liveSessionIds: new Set(),
ptyBoundAt: args.ptyBoundAt,
snapshotRequestedAt: args.livenessRequestedAt
})
}
export function reconcileMissingSessions(args: {
bindings: Iterable<ReconcilableBinding>
hasPty: HasPty
}): void {
// Why: the liveness request time must predate every async response so a
// stale response cannot close a PTY that bound after the request started.
const requestedAt = performance.now()
for (const binding of args.bindings) {
binding.reconcileIfSessionMissing?.(args.hasPty, requestedAt)
}
}
/**
* Thin orchestration: fetch the live session listing once and invoke each
* binding's `reconcileIfSessionDead` with the resolved set.
@@ -8,7 +8,6 @@ import {
resolvePaneLinkCwd,
resolvePaneSeedCwd,
resolveQueuedInitialCwd,
scheduleVisibilityReconcilePass,
shouldDetachPaneTransportOnUnmount,
splitPaneWithOneShotStartup,
suppressIntentionalPaneCloseExit
@@ -291,7 +290,7 @@ describe('suppressIntentionalPaneCloseExit', () => {
})
})
describe('scheduleVisibilityReconcilePass', () => {
describe('terminal pane visibility resume tracking', () => {
it('ignores previous visibility from a different terminal identity', () => {
expect(
getPreviousVisibleForTerminalPane({
@@ -324,57 +323,4 @@ describe('scheduleVisibilityReconcilePass', () => {
)
expect(isTerminalPaneVisibilityResume({ previousIsVisible: false, isVisible: true })).toBe(true)
})
it('schedules a reconcile pass over the bindings when becoming visible', async () => {
const reconcileIfSessionDead = vi.fn()
const listSessions = vi
.fn<() => Promise<{ id: string; cwd: string; title: string }[]>>()
.mockResolvedValue([{ id: 'live-1', cwd: '/a', title: 'a' }])
const scheduled = scheduleVisibilityReconcilePass({
previousIsVisible: false,
isVisible: true,
bindings: [{ reconcileIfSessionDead }],
listSessions
})
expect(scheduled).toBe(true)
// Fire-and-forget: let the async listSessions resolve before asserting.
await Promise.resolve()
await Promise.resolve()
expect(listSessions).toHaveBeenCalledTimes(1)
expect(reconcileIfSessionDead).toHaveBeenCalledWith(new Set(['live-1']), expect.any(Number))
})
it('does not schedule on an initially visible mount', () => {
const listSessions = vi
.fn<() => Promise<{ id: string; cwd: string; title: string }[]>>()
.mockResolvedValue([])
const scheduled = scheduleVisibilityReconcilePass({
previousIsVisible: null,
isVisible: true,
bindings: [{ reconcileIfSessionDead: vi.fn() }],
listSessions
})
expect(scheduled).toBe(false)
expect(listSessions).not.toHaveBeenCalled()
})
it('self-gates: does not schedule when hiding (isVisible false)', () => {
const listSessions = vi
.fn<() => Promise<{ id: string; cwd: string; title: string }[]>>()
.mockResolvedValue([])
const scheduled = scheduleVisibilityReconcilePass({
previousIsVisible: true,
isVisible: false,
bindings: [{ reconcileIfSessionDead: vi.fn() }],
listSessions
})
expect(scheduled).toBe(false)
expect(listSessions).not.toHaveBeenCalled()
})
})
@@ -78,8 +78,11 @@ import { installMouseHideWhileTyping } from './mouse-hide-while-typing'
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { connectPanePty } from './pty-connection'
import { reconcileDeadSessions, type ReconcilableBinding } from './terminal-dead-session-reconcile'
import type { PtyTransport } from './pty-transport'
import {
reconcileMissingSessions,
type ReconcilableBinding
} from './terminal-dead-session-reconcile'
import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { getConnectionId } from '@/lib/connection-context'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
@@ -473,21 +476,6 @@ export function getPreviousVisibleForTerminalPane(args: {
return args.previous.isVisible
}
export function scheduleVisibilityReconcilePass(args: {
previousIsVisible: boolean | null
isVisible: boolean
bindings: Iterable<ReconcilableBinding>
listSessions: () => Promise<{ id: string; cwd: string; title: string }[]>
}): boolean {
if (!isTerminalPaneVisibilityResume(args)) {
return false
}
// Why: fire-and-forget so the async listSessions IPC never blocks the
// synchronous WebGL/fit resume work the user sees first.
void reconcileDeadSessions({ bindings: args.bindings, listSessions: args.listSessions })
return true
}
export function useTerminalPaneLifecycle({
tabId,
worktreeId,
@@ -1694,23 +1682,20 @@ export function useTerminalPaneLifecycle({
noteVisibilityResume?: () => void
}
bindingWithVisibility.syncProcessTracking?.()
// Why: re-arm the once-per-resume input liveness re-check so the typing
// hot path stays off the listSessions IPC between resumes (the re-check
// is only useful right after a hidden→visible flip).
// Why: visible-resume repairs dropped hidden resizes, but it must not fit
// against xterm's transient hidden DOM fallback.
if (resumedFromHidden) {
bindingWithVisibility.noteVisibilityResume?.()
}
}
// Why: the reconcile pass self-gates on becoming visible (resume) — the
// effect also fires on hide and initial mount. Initial visible mounts are
// fresh PTY startup, so an early listSessions snapshot must not close the
// newborn tab before the daemon lists it.
scheduleVisibilityReconcilePass({
previousIsVisible,
isVisible,
bindings: panePtyBindingsRef.current.values() as Iterable<ReconcilableBinding>,
listSessions: () => window.api.pty.listSessions()
})
if (resumedFromHidden && typeof window.api.pty.hasPty === 'function') {
// Why: preserve missed-exit recovery without daemon-wide listSessions;
// providers can answer a single-PTY liveness check from in-memory state.
reconcileMissingSessions({
bindings: panePtyBindingsRef.current.values() as Iterable<ReconcilableBinding>,
hasPty: window.api.pty.hasPty
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Why: visibility and terminal identity changes must refresh existing PTY process tracking even though the ref object identity is stable.
}, [cwd, isVisible, isVisibleRef, panePtyBindingsRef, tabId])
+1
View File
@@ -2547,6 +2547,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
getCwd: () => Promise.resolve('~'),
getSize: () => Promise.resolve(null),
listSessions: () => Promise.resolve([]),
hasPty: () => Promise.resolve(null),
getMainBufferSnapshot: () => Promise.resolve(null),
getRendererDeliveryDebugSnapshot: () =>
Promise.resolve({