Recover frozen terminal panes after a backgrounded agent exits

When a terminal pane's PTY/daemon session is reaped while its worktree is
surface-hidden, the renderer never observes the exit, so the pane stays
mounted bound to a dead session: on return it shows a stale frozen frame
and silently swallows keyboard input.

On visibility resume, reconcile any pane bound to a genuinely-reaped
local/daemon session by routing it through the same teardown an observed
exit runs. As defense-in-depth, re-check liveness once per resume on the
first keystroke. SSH and web-runtime (remote:) panes are deliberately not
reconciled (the flat listSessions IPC cannot prove their liveness).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-27 12:52:16 -07:00
co-authored by Orca
parent c7153a51b4
commit 3e29bd68bd
6 changed files with 648 additions and 0 deletions
@@ -145,6 +145,7 @@ type MockTransport = {
sendInputAccepted?: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
getPtyId: ReturnType<typeof vi.fn>
getConnectionId: ReturnType<typeof vi.fn>
serializeBuffer?: ReturnType<typeof vi.fn>
}
@@ -276,6 +277,7 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport
sendInput: vi.fn(() => true),
resize: vi.fn(() => true),
getPtyId: vi.fn(() => ptyId),
getConnectionId: vi.fn(() => null),
serializeBuffer: undefined
} as MockTransport
const sendInput = transport.sendInput as unknown as (data: string) => boolean
@@ -580,6 +582,7 @@ describe('connectPanePty', () => {
},
pty: {
signal: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
getMainBufferSnapshot: vi.fn().mockResolvedValue(null),
getForegroundProcess: vi.fn().mockResolvedValue(null),
hasChildProcesses: vi.fn().mockResolvedValue(false),
@@ -9478,4 +9481,293 @@ describe('connectPanePty', () => {
expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null)
expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled()
})
describe('reconcileIfSessionDead', () => {
it('closes a split pane bound to a dead local session (same teardown as onExit)', 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 binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
// Why: clear the freshly-split early-return guard so onExit reaches the
// close branch, matching an established split pane the user has used.
capturedDataCallback.current?.('shell prompt')
binding.reconcileIfSessionDead(new Set(['pty-pane-1']))
expect(manager.closePane).toHaveBeenCalledWith(2)
})
it('routes the last pane through onPtyExitRef when its session is dead', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-pane-1')
transportFactoryQueue.push(transport)
const manager = createManager(1)
const deps = createDeps()
const binding = connectPanePty(createPane(1) as never, manager as never, deps as never)
// The last pane reattaches to the tab's persisted ptyId ('tab-pty').
binding.reconcileIfSessionDead(new Set(['some-other-live']))
expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty')
expect(manager.closePane).not.toHaveBeenCalled()
})
it('is a no-op when the bound session is still live', 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.reconcileIfSessionDead(new Set(['pty-pane-1', 'pty-pane-2']))
expect(manager.closePane).not.toHaveBeenCalled()
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
})
it('is a no-op for remote: web-runtime ids', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment('env-1')
const transport = createMockTransport('remote:env-1:pane-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 binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
binding.reconcileIfSessionDead(new Set())
expect(manager.closePane).not.toHaveBeenCalled()
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
})
it('is a no-op for SSH/non-local ids (non-null connectionId)', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-pane-2')
transport.getConnectionId.mockReturnValue('ssh-target-1')
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.reconcileIfSessionDead(new Set())
expect(manager.closePane).not.toHaveBeenCalled()
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
})
it('respects suppression: a suppressed dead session keeps the pane mounted', 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,
consumeSuppressedPtyExit: vi.fn(() => true),
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
binding.reconcileIfSessionDead(new Set(['pty-pane-1']))
expect(deps.consumeSuppressedPtyExit).toHaveBeenCalledWith('pty-pane-2')
expect(manager.closePane).not.toHaveBeenCalled()
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
})
it('does not act on a stale id after a reattach changed the bound id', 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)
// Reattach to a new live session between snapshot and apply: the snapshot
// marked the OLD id dead, but getPtyId now returns the new (live) id.
transport.getPtyId.mockReturnValue('pty-pane-2-reattached')
binding.reconcileIfSessionDead(new Set(['pty-pane-1', 'pty-pane-2-reattached']))
expect(manager.closePane).not.toHaveBeenCalled()
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
})
it('closes a split pane exactly once across reconcile + a racing real exit', 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 binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
capturedDataCallback.current?.('shell prompt')
const onPtyExit = createdTransportOptions[0]?.onPtyExit as
| ((ptyId: string) => void)
| undefined
expect(onPtyExit).toBeTypeOf('function')
binding.reconcileIfSessionDead(new Set(['pty-pane-1']))
// A racing real/synthetic pty:exit for the SAME id must not close twice.
onPtyExit?.('pty-pane-2')
expect(manager.closePane).toHaveBeenCalledTimes(1)
expect(manager.closePane).toHaveBeenCalledWith(2)
})
it('closes a genuinely-dead non-suppressed pane once (not misread as suppressed)', 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)
// consumeSuppressedPtyExit always returns false for this genuinely-dead id.
const deps = createDeps({
restoredLeafId: LEAF_2,
consumeSuppressedPtyExit: vi.fn(() => false),
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const binding = connectPanePty(createPane(2) as never, manager as never, deps as never)
capturedDataCallback.current?.('shell prompt')
const onPtyExit = createdTransportOptions[0]?.onPtyExit as
| ((ptyId: string) => void)
| undefined
binding.reconcileIfSessionDead(new Set(['pty-pane-1']))
onPtyExit?.('pty-pane-2')
// Observable outcome: single close, pane was treated as dead (closed).
expect(manager.closePane).toHaveBeenCalledTimes(1)
expect(manager.closePane).toHaveBeenCalledWith(2)
})
})
describe('input liveness re-check 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.
async function connectActivePaneWithInput(): Promise<{
binding: { noteVisibilityResume: () => void }
typeKeystroke: (data?: string) => void
}> {
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 pane = createPane(2)
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
noteVisibilityResume: () => void
}
return {
binding,
// Drives the real xterm onData (terminal input) handler.
typeKeystroke: (data = 'a') => sendTerminalInputThroughPane(pane, data)
}
}
it('fires listSessions at most once across many keystrokes in one resume window', async () => {
const listSessions = vi.mocked(window.api.pty.listSessions)
listSessions.mockClear()
const { typeKeystroke } = await connectActivePaneWithInput()
for (let i = 0; i < 25; i++) {
typeKeystroke('x')
}
expect(listSessions).toHaveBeenCalledTimes(1)
})
it('re-arms one re-check after a visibility resume', async () => {
const listSessions = vi.mocked(window.api.pty.listSessions)
listSessions.mockClear()
const { binding, typeKeystroke } = await connectActivePaneWithInput()
typeKeystroke('a')
typeKeystroke('b')
expect(listSessions).toHaveBeenCalledTimes(1)
// Resume re-arms exactly one more re-check.
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)
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'x')
sendTerminalInputThroughPane(pane, 'y')
expect(listSessions).not.toHaveBeenCalled()
})
})
})
@@ -20,6 +20,7 @@ import {
hasCachedWindowsTerminalCapabilities
} from '@/lib/windows-terminal-capabilities'
import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding'
import { shouldReconcileDeadSession } from './terminal-dead-session-reconcile'
import type { PtyConnectionDeps } from './pty-connection-types'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides'
@@ -506,6 +507,8 @@ let inactiveForegroundImmediateBudgetWindowStart = 0
type PanePtyBinding = IDisposable & {
syncProcessTracking: () => void
noteVisibilityResume: () => void
reconcileIfSessionDead: (liveSessionIds: Set<string>) => void
}
function isAgentTaskCompleteNotificationEnabled(): boolean {
@@ -1293,7 +1296,17 @@ export function connectPanePty(
}
})
// Why: the transport's own exit handler (pty-transport.ts) normally makes
// onExit run-at-most-once by clearing connected/ptyId + unregistering BEFORE
// calling it. reconcileIfSessionDead drives onExit directly (bypassing that),
// so this binding-local one-shot guards the body so reconcile and any racing
// real/synthetic pty:exit for the same id close the pane exactly once.
let onExitHasRun = false
const onExit = (ptyId: string): void => {
if (onExitHasRun) {
return
}
onExitHasRun = true
agentCompletionCoordinator.dispose()
clearPanePtyFitBinding()
const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId)
@@ -1925,6 +1938,11 @@ 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
@@ -4134,10 +4152,89 @@ export function connectPanePty(
scheduleRuntimeGraphSync()
})
// Why: on visibility resume a pane may still be bound to a daemon session
// reaped while hidden (the missed-exit defect). Route it through the SAME
// teardown a real onExit runs. Re-validate identity at apply time so a
// reattach racing the listSessions snapshot is never clobbered, and respect
// the remote/SSH guards. Suppression semantics come for free via onExit
// (which consults consumeSuppressedPtyExit) plus the one-shot guard above.
const reconcileIfSessionDead = (liveSessionIds: Set<string>): void => {
if (disposed || onExitHasRun) {
return
}
const currentPtyId = transport.getPtyId()
if (
!currentPtyId ||
!shouldReconcileDeadSession({
ptyId: currentPtyId,
connectionId: transport.getConnectionId?.(),
liveSessionIds
})
) {
return
}
onExit(currentPtyId)
}
// Why (perf): the only moment a daemon session can be reaped behind the
// renderer's back is while the pane was surface-hidden. So the input-driven
// re-check is only useful in the window right after a resume — once it (or
// the resume pass) has confirmed liveness for this resume, re-polling on
// every subsequent keystroke is pure waste: listSessions() is a
// renderer→main→daemon round-trip (DaemonPtyAdapter.listProcesses requests
// `listSessions` from the daemon subprocess), so an ungated per-keystroke
// re-check would put a process-enumeration round-trip on the typing hot path
// for every healthy local pane. Fire at most ONCE per resume window; reset
// on the next hide→show. This preserves the "reduces not eliminates the
// first-keystroke drop" intent — the first keystroke after a resume still
// triggers exactly one re-check.
let livenessRecheckFiredSinceResume = 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 || onExitHasRun || livenessRecheckFiredSinceResume) {
return
}
const currentPtyId = transport.getPtyId()
const currentConnectionId = transport.getConnectionId?.()
if (
!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)
) {
return
}
// Why: set BEFORE the IPC so concurrent keystrokes coalesce to one in-flight
// request rather than fanning out a round-trip per byte typed.
livenessRecheckFiredSinceResume = true
void window.api.pty
.listSessions()
.then((sessions) => {
reconcileIfSessionDead(new Set(sessions.map((session) => session.id)))
})
// Why: a rejected listing is "unknown" — never close a pane on it.
.catch(() => {})
}
return {
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.
noteVisibilityResume() {
livenessRecheckFiredSinceResume = false
},
reconcileIfSessionDead,
dispose() {
disposed = true
if (terminalKeyTargetSupportsEvents) {
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest'
import {
reconcileDeadSessions,
shouldReconcileDeadSession
} from './terminal-dead-session-reconcile'
describe('shouldReconcileDeadSession', () => {
it('reconciles a local, non-remote id genuinely absent from the live set', () => {
expect(
shouldReconcileDeadSession({
ptyId: 'wt@@dead',
connectionId: null,
liveSessionIds: new Set(['wt@@alive'])
})
).toBe(true)
})
it('does not reconcile when the bound id is still live', () => {
expect(
shouldReconcileDeadSession({
ptyId: 'wt@@alive',
connectionId: null,
liveSessionIds: new Set(['wt@@alive'])
})
).toBe(false)
})
it('skips a mid-spawn pane with no bound id', () => {
expect(
shouldReconcileDeadSession({
ptyId: null,
connectionId: null,
liveSessionIds: new Set()
})
).toBe(false)
})
it('skips remote: web-runtime ids', () => {
expect(
shouldReconcileDeadSession({
ptyId: 'remote:env-1:abc',
connectionId: null,
liveSessionIds: new Set()
})
).toBe(false)
})
it('skips SSH/non-local ids (non-null connectionId)', () => {
expect(
shouldReconcileDeadSession({
ptyId: 'wt@@ssh-dead',
connectionId: 'ssh-target-1',
liveSessionIds: new Set(['wt@@alive'])
})
).toBe(false)
})
it('reconciles a genuinely-absent local id even when the live set is empty (no zero-total skip)', () => {
expect(
shouldReconcileDeadSession({
ptyId: 'wt@@dead',
connectionId: null,
liveSessionIds: new Set()
})
).toBe(true)
})
})
describe('reconcileDeadSessions', () => {
function createBinding() {
return { reconcileIfSessionDead: vi.fn<(liveSessionIds: Set<string>) => void>() }
}
it('invokes each binding with the resolved live-session id set', async () => {
const bindingA = createBinding()
const bindingB = createBinding()
await reconcileDeadSessions({
bindings: [bindingA, bindingB],
listSessions: async () => [
{ id: 'wt@@alive', cwd: '/a', title: 'a' },
{ id: 'wt@@other', cwd: '/b', title: 'b' }
]
})
const expectedSet = new Set(['wt@@alive', 'wt@@other'])
expect(bindingA.reconcileIfSessionDead).toHaveBeenCalledWith(expectedSet)
expect(bindingB.reconcileIfSessionDead).toHaveBeenCalledWith(expectedSet)
})
it('treats a rejected listSessions as "unknown" and reconciles nothing', async () => {
const binding = createBinding()
await reconcileDeadSessions({
bindings: [binding],
listSessions: async () => {
throw new Error('IPC failed')
}
})
expect(binding.reconcileIfSessionDead).not.toHaveBeenCalled()
})
it('treats a resolved empty list as authoritative (still reconciles)', async () => {
const binding = createBinding()
await reconcileDeadSessions({
bindings: [binding],
listSessions: async () => []
})
expect(binding.reconcileIfSessionDead).toHaveBeenCalledWith(new Set())
})
})
@@ -0,0 +1,76 @@
// Renderer-side liveness reconciliation for terminal panes whose daemon PTY
// session was reaped while the worktree was surface-hidden. On visibility
// resume the missed `pty:exit` left the pane mounted bound to a dead session;
// this routes such panes through the same exit teardown an observed exit runs.
// See design-docs/terminal-dead-pane-on-bg-exit.md.
const REMOTE_PTY_ID_PREFIX = 'remote:'
/**
* A pane binding that exposes its bound transport identity plus a reconcile
* hook. Kept structural so the lifecycle fan-out can call it without importing
* the full `PanePtyBinding` shape from pty-connection.
*/
export type ReconcilableBinding = {
reconcileIfSessionDead?: (liveSessionIds: Set<string>) => void
}
/**
* PURE decision: should the pane bound to `ptyId` be reconciled (torn down)
* given the resolved set of live session ids?
*
* Reconcile ONLY when every guard passes:
* - `ptyId` is non-null (a mid-spawn pane has no id to prove dead).
* - `ptyId` is not `remote:`-prefixed (web-runtime liveness is owned by the
* host snapshot, not `listSessions`).
* - `connectionId === null` — the id is local/daemon-backed. SSH-backed ids
* (non-null connectionId) are deferred: the flat `listSessions` shape cannot
* authoritatively prove an SSH session gone (see design Section 1).
* - the id is genuinely absent from the resolved live set.
*/
export function shouldReconcileDeadSession(args: {
ptyId: string | null | undefined
connectionId: string | null | undefined
liveSessionIds: Set<string>
}): boolean {
const { ptyId, connectionId, liveSessionIds } = args
if (ptyId === null || ptyId === undefined) {
return false
}
if (ptyId.startsWith(REMOTE_PTY_ID_PREFIX)) {
return false
}
// Why: only local/daemon-backed ids (connectionId null/undefined) are
// reconcilable; a non-null connectionId means SSH, which is deferred.
if (connectionId !== null && connectionId !== undefined) {
return false
}
return !liveSessionIds.has(ptyId)
}
/**
* Thin orchestration: fetch the live session listing once and invoke each
* binding's `reconcileIfSessionDead` with the resolved set.
*
* Why a rejected `listSessions()` is swallowed (no reconcile): the local
* provider listing is NOT `.catch`-wrapped in main, so a rejection means the
* listing is unknown — closing a pane on an IPC/listing failure is unacceptable.
* A RESOLVED list (even empty `[]`) is AUTHORITATIVE for local ids: a local id
* absent from it is genuinely reaped, so there is no zero-total skip.
*/
export async function reconcileDeadSessions(args: {
bindings: Iterable<ReconcilableBinding>
listSessions: () => Promise<{ id: string; cwd: string; title: string }[]>
}): Promise<void> {
let sessions: { id: string }[]
try {
sessions = await args.listSessions()
} catch {
// Why: a rejected listing is "unknown" — never close a pane on it.
return
}
const liveSessionIds = new Set(sessions.map((session) => session.id))
for (const binding of args.bindings) {
binding.reconcileIfSessionDead?.(liveSessionIds)
}
}
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import {
mapRestoredPaneTitlesByPaneId,
scheduleVisibilityReconcilePass,
shouldDetachPaneTransportOnUnmount,
splitPaneWithOneShotStartup,
suppressIntentionalPaneCloseExit
@@ -182,3 +183,40 @@ describe('suppressIntentionalPaneCloseExit', () => {
expect(suppressPtyExit).not.toHaveBeenCalled()
})
})
describe('scheduleVisibilityReconcilePass', () => {
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({
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']))
})
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({
isVisible: false,
bindings: [{ reconcileIfSessionDead: vi.fn() }],
listSessions
})
expect(scheduled).toBe(false)
expect(listSessions).not.toHaveBeenCalled()
})
})
@@ -70,6 +70,7 @@ 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 { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream'
import { getConnectionId } from '@/lib/connection-context'
@@ -361,6 +362,26 @@ export function shouldDetachPaneTransportOnUnmount(args: {
)
}
/**
* Self-gating dead-session reconcile pass scheduled from the isVisible effect.
* Why self-gate: the effect fires on BOTH isVisible true and false, but we only
* reconcile on resume (becoming visible), never on hide. Returns true when the
* pass was scheduled so the resume-unit test can assert the gate.
*/
export function scheduleVisibilityReconcilePass(args: {
isVisible: boolean
bindings: Iterable<ReconcilableBinding>
listSessions: () => Promise<{ id: string; cwd: string; title: string }[]>
}): boolean {
if (!args.isVisible) {
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,
@@ -1504,9 +1525,25 @@ export function useTerminalPaneLifecycle({
for (const panePtyBinding of panePtyBindingsRef.current.values()) {
const bindingWithVisibility = panePtyBinding as IDisposable & {
syncProcessTracking?: () => void
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).
if (isVisible) {
bindingWithVisibility.noteVisibilityResume?.()
}
}
// Why: the reconcile pass self-gates on becoming visible (resume) — the
// effect also fires on hide — and runs fire-and-forget alongside
// syncProcessTracking. reconcileDeadSessions re-validates identity at apply
// time so a racing reattach is not clobbered.
scheduleVisibilityReconcilePass({
isVisible,
bindings: panePtyBindingsRef.current.values() as Iterable<ReconcilableBinding>,
listSessions: () => window.api.pty.listSessions()
})
// eslint-disable-next-line react-hooks/exhaustive-deps -- Why: visibility flips must refresh existing PTY process tracking even though the ref object identity is stable.
}, [isVisible, isVisibleRef, panePtyBindingsRef])