fix(terminal): resolve the waiting pane's tab by either id and re-arm after wake

The wake listener looked the tab up by the pane's render id, which can be a
unified id whose terminal tab lives under entityId, so the generation check
declined forever for those panes. Mount, fresh spawn, and the wake listener now
share one live resolver. The wait flag resets when the listener fires so a
second sleep can hold the pane again, listener dispatch is guarded, folder
activation clears after its own set(), and the sleep flow re-asserts the marker
after each teardown while releasing a workspace the user activated meanwhile.
This commit is contained in:
Jinwoo-H
2026-09-03 18:08:02 -04:00
parent d0472e7f92
commit 81fe59a31c
10 changed files with 193 additions and 92 deletions
@@ -1,9 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const state = {
activeWorktreeId: null as string | null,
setActiveWorktree: vi.fn(),
const state: {
activeWorktreeId: string | null
setActiveWorktree: ReturnType<typeof vi.fn>
shutdownWorktreeBrowsers: ReturnType<typeof vi.fn>
shutdownWorktreeTerminals: ReturnType<typeof vi.fn>
suppressPtyExit: ReturnType<typeof vi.fn>
consumeSuppressedPtyExit: ReturnType<typeof vi.fn>
tabsByWorktree: Record<string, { id: string }[]>
ptyIdsByTabId: Record<string, string[]>
} = {
activeWorktreeId: null,
setActiveWorktree: vi.fn((worktreeId: string | null) => {
state.activeWorktreeId = worktreeId
}),
shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined),
shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined),
suppressPtyExit: vi.fn(),
@@ -190,6 +201,34 @@ describe('runSleepWorktree', () => {
expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled()
})
it('leaves a worktree the user activated mid-batch awake', async () => {
let releaseFirst: () => void = () => {}
mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseFirst = resolve
})
)
const run = runSleepWorktrees(['wt-1', 'wt-2'])
await Promise.resolve()
// Why: the user clicked wt-2 while wt-1 was tearing down; sleeping it anyway
// must not leave the active workspace marked with no clear pending.
mocks.state.activeWorktreeId = 'wt-2'
releaseFirst()
await run
expect(mocks.clearWorktreeSleepIntent).toHaveBeenLastCalledWith('wt-2')
})
it('re-asserts the marker after teardown so a late PTY bind cannot un-sleep it', async () => {
await runSleepWorktree('wt-1')
const marks = mocks.markWorktreeSleepIntent.mock.invocationCallOrder
const terminalShutdown = mocks.state.shutdownWorktreeTerminals.mock.invocationCallOrder[0]
expect(marks.some((order) => order > terminalShutdown)).toBe(true)
})
it('marks each worktree only when its own teardown starts', async () => {
let releaseFirst: () => void = () => {}
mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce(
@@ -186,6 +186,14 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise
if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) {
await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId })
}
// Why: a spawn that resolved during teardown binds a PTY and clears the marker;
// the workspace is asleep now, so re-assert it. A workspace the user activated
// meanwhile is awake by their choice and must not be left marked.
if (useAppStore.getState().activeWorktreeId === worktreeId) {
clearWorktreeSleepIntent(worktreeId)
} else {
markWorktreeSleepIntent(worktreeId)
}
} catch (err) {
console.error('[sleep-worktree] terminal or host suspension failed', {
worktreeId,
@@ -246,6 +246,61 @@ describe('deliberate sleep keeps mounted panes cold', () => {
expect(transport.connect).not.toHaveBeenCalled()
})
it('resumes a waiting pane mounted under a unified tab id', async () => {
const { connectPanePty } = await import('./pty-connection')
const { clearWorktreeSleepIntent, markWorktreeSleepIntent } =
await import('@/lib/worktree-sleep-intent')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
activeWorktreeId: 'wt-other',
tabsByWorktree: { 'wt-1': [{ id: 'tab-entity', ptyId: null, generation: 3 }] },
getTab: (id: string) =>
id === 'unified-1' ? { id, contentType: 'terminal', entityId: 'tab-entity' } : null
} as never
markWorktreeSleepIntent('wt-1')
const deps = createDeps({ tabId: 'unified-1', isVisibleRef: { current: false } })
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
await flushAsyncTicks()
expect(transport.connect).not.toHaveBeenCalled()
clearWorktreeSleepIntent('wt-1')
await flushAsyncTicks()
expect(transport.connect).toHaveBeenCalledTimes(1)
})
it('re-arms after a wake so a second sleep can hold the pane again', async () => {
const { connectPanePty } = await import('./pty-connection')
const { clearWorktreeSleepIntent, markWorktreeSleepIntent } =
await import('@/lib/worktree-sleep-intent')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
let releaseCwd: (cwd: string) => void = () => {}
const cwdPromise = new Promise<string>((resolve) => {
releaseCwd = resolve
})
markWorktreeSleepIntent('wt-1')
const deps = createDeps({ tabId: 'tab-resleep', isVisibleRef: { current: false }, cwdPromise })
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
await flushAsyncTicks()
// Wake: the pane leaves the sleep gate and parks on the cwd gate.
clearWorktreeSleepIntent('wt-1')
await flushAsyncTicks()
// Sleep again before the cwd settles, then wake again.
markWorktreeSleepIntent('wt-1')
releaseCwd('/cwd')
await flushAsyncTicks()
expect(transport.connect).not.toHaveBeenCalled()
clearWorktreeSleepIntent('wt-1')
await flushAsyncTicks()
expect(transport.connect).toHaveBeenCalledTimes(1)
})
it('drops the wake listener when a waiting pane is disposed', async () => {
const { connectPanePty } = await import('./pty-connection')
const { clearWorktreeSleepIntent, markWorktreeSleepIntent } =
@@ -36,7 +36,7 @@ import { installPtyInputRecovery } from './pty-input-recovery'
import { installPtyInputForward } from './pty-input-forward'
import { installPtyResizeGeometry } from './pty-resize-geometry'
import { installSessionReconcileDispose } from './session-reconcile-dispose'
import { resolveTerminalTabId } from './terminal-tab-id'
import { findTerminalTabForPane } from './terminal-tab-id'
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
@@ -50,43 +50,8 @@ export function connectPanePty(
const session = { pane, manager, deps } as ConnectPanePtySession
session.shouldRefreshForegroundSynchronously = (): boolean =>
!session.manager.hasWebglRenderer(session.pane.id)
const state = useAppStore.getState()
const unifiedTab = state.getTab?.(deps.tabId)
const initialOwnerWorktreeId =
state.getTerminalTabOwnerWorktreeId?.(deps.tabId) ??
(unifiedTab?.contentType === 'terminal'
? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId)
: null)
const terminalTabId = resolveTerminalTabId(
{
getTab: state.getTab,
hasTerminalTab: (candidateId) =>
Boolean(
state.tabsByWorktree[deps.worktreeId]?.some(
(candidate) => candidate.id === candidateId
) ||
(initialOwnerWorktreeId
? state.tabsByWorktree[initialOwnerWorktreeId]?.some(
(candidate) => candidate.id === candidateId
)
: false)
)
},
deps.tabId
)
const ownerWorktreeId =
state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId
const terminalTab =
state.tabsByWorktree[deps.worktreeId]?.find((candidate) => candidate.id === terminalTabId) ??
(ownerWorktreeId
? state.tabsByWorktree[ownerWorktreeId]?.find((candidate) => candidate.id === terminalTabId)
: undefined) ??
// Why: folder/worktree migrations can leave the pane's render key stale for one commit.
Object.values(state.tabsByWorktree)
.find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId))
?.find((candidate) => candidate.id === terminalTabId)
const tab = terminalTab ?? (unifiedTab && 'generation' in unifiedTab ? unifiedTab : null)
session.tabGeneration = tab?.generation ?? 0
session.tabGeneration =
findTerminalTabForPane(useAppStore.getState(), deps.worktreeId, deps.tabId)?.generation ?? 0
// Why: recovery ownership belongs to this xterm instance. A request that
// settles after remount must not remount its already-replaced successor.
session.terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(session.deps.tabId)
@@ -14,7 +14,7 @@ import type {
} from './fresh-spawn-types'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { resolveTerminalTabId } from './terminal-tab-id'
import { findTerminalTabForPane } from './terminal-tab-id'
export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
session.startFreshSpawn = (
@@ -113,51 +113,10 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
shouldContinue: () => {
const state = useAppStore.getState()
const unifiedTab = state.getTab?.(session.deps.tabId)
const initialOwnerWorktreeId =
state.getTerminalTabOwnerWorktreeId?.(session.deps.tabId) ??
(unifiedTab?.contentType === 'terminal'
? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId)
: null)
const terminalTabId = resolveTerminalTabId(
{
getTab: state.getTab,
hasTerminalTab: (candidateId) =>
Boolean(
state.tabsByWorktree[session.deps.worktreeId]?.some(
(candidate) => candidate.id === candidateId
) ||
(initialOwnerWorktreeId
? state.tabsByWorktree[initialOwnerWorktreeId]?.some(
(candidate) => candidate.id === candidateId
)
: false)
)
},
session.deps.tabId
)
const ownerWorktreeId =
state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId
const terminalTab =
state.tabsByWorktree[session.deps.worktreeId]?.find(
(candidate) => candidate.id === terminalTabId
) ??
(ownerWorktreeId
? state.tabsByWorktree[ownerWorktreeId]?.find(
(candidate) => candidate.id === terminalTabId
)
: undefined)
const fallbackTab = Object.values(state.tabsByWorktree)
.find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId))
?.find((candidate) => candidate.id === terminalTabId)
const currentTab =
terminalTab ??
fallbackTab ??
(unifiedTab && 'generation' in unifiedTab ? unifiedTab : null)
return !session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration
},
shouldContinue: () =>
!session.disposed &&
(findTerminalTabForPane(useAppStore.getState(), session.deps.worktreeId, session.deps.tabId)
?.generation ?? 0) === session.tabGeneration,
callbacks: outputCallbacks.callbacks
})
@@ -7,6 +7,7 @@ import { hasWorktreeSleepIntent, onWorktreeSleepIntentCleared } from '@/lib/work
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
import { recordPtyConnectDiagnostic } from './pty-connect-limits'
import { findTerminalTabForPane } from './terminal-tab-id'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { bindBuildColdRestoreAgentResumeStartup } from './cold-restore-resume-startup'
@@ -50,15 +51,18 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void
`pane=${session.pane.id} tab=${session.deps.tabId} -> WAIT FOR WAKE (deliberate sleep)`
)
const unsubscribe = onWorktreeSleepIntentCleared(session.deps.worktreeId, () => {
wakeWaitStarted = false
const index = session.waitTeardowns.indexOf(unsubscribe)
if (index !== -1) {
session.waitTeardowns.splice(index, 1)
}
// Why: an activation wake bumps the tab generation in the same tick and the
// remounted pane connects on its own; a stale generation must not connect too.
const currentTab = Object.values(useAppStore.getState().tabsByWorktree)
.flat()
.find((candidate) => candidate.id === session.deps.tabId)
const currentTab = findTerminalTabForPane(
useAppStore.getState(),
session.deps.worktreeId,
session.deps.tabId
)
if (!session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration) {
session.runDeferredConnect()
}
@@ -13,3 +13,54 @@ export function resolveTerminalTabId(state: TerminalTabLookup, tabId: string): s
const unifiedTab = state.getTab?.(tabId)
return unifiedTab?.contentType === 'terminal' ? unifiedTab.entityId : tabId
}
type TerminalTabRecord = { id: string; generation?: number }
type TerminalTabState = {
getTab?: (
tabId: string
) => ({ contentType: string; entityId: string } & Partial<TerminalTabRecord>) | null
tabsByWorktree: Record<string, readonly TerminalTabRecord[]>
getTerminalTabOwnerWorktreeId?: (tabId: string) => string | null | undefined
}
/**
* Resolve the live terminal tab (or unified tab) a pane renders for, by either id
* form. Why the fallbacks: folder/worktree migrations can leave the pane's render
* key stale for one commit, and a unified id's terminal tab lives under entityId.
*/
export function findTerminalTabForPane(
state: TerminalTabState,
worktreeId: string,
tabId: string
): TerminalTabRecord | null {
const unifiedTab = state.getTab?.(tabId)
const initialOwnerWorktreeId =
state.getTerminalTabOwnerWorktreeId?.(tabId) ??
(unifiedTab?.contentType === 'terminal'
? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId)
: null)
const hasTabIn = (id: string | null | undefined, candidateId: string): boolean =>
Boolean(id && state.tabsByWorktree[id]?.some((candidate) => candidate.id === candidateId))
const terminalTabId = resolveTerminalTabId(
{
getTab: state.getTab,
hasTerminalTab: (candidateId) =>
hasTabIn(worktreeId, candidateId) || hasTabIn(initialOwnerWorktreeId, candidateId)
},
tabId
)
const ownerWorktreeId =
state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId
const byId = (id: string | null | undefined): TerminalTabRecord | undefined =>
id ? state.tabsByWorktree[id]?.find((candidate) => candidate.id === terminalTabId) : undefined
return (
byId(worktreeId) ??
byId(ownerWorktreeId) ??
Object.values(state.tabsByWorktree)
.flat()
.find((candidate) => candidate.id === terminalTabId) ??
(unifiedTab && 'generation' in unifiedTab
? { id: unifiedTab.entityId, generation: unifiedTab.generation }
: null)
)
}
@@ -15,7 +15,12 @@ export function clearWorktreeSleepIntent(worktreeId: string | null): void {
const listeners = wakeListenersByWorktreeId.get(worktreeId)
wakeListenersByWorktreeId.delete(worktreeId)
for (const listener of listeners ?? []) {
listener()
try {
listener()
} catch (error) {
// Why: one pane's connect failure must not strand its siblings or throw out of a store action.
console.error('[sleep-intent] wake listener failed', { worktreeId, error })
}
}
}
@@ -120,6 +120,20 @@ describe('worktree sleep intent lifecycle', () => {
unsubscribe()
})
it('keeps notifying siblings when one wake listener throws', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const woke = vi.fn()
markWorktreeSleepIntent(WORKTREE_ID)
intent.onWorktreeSleepIntentCleared(WORKTREE_ID, () => {
throw new Error('boom')
})
intent.onWorktreeSleepIntentCleared(WORKTREE_ID, woke)
expect(() => clearWorktreeSleepIntent(WORKTREE_ID)).not.toThrow()
expect(woke).toHaveBeenCalledTimes(1)
errorSpy.mockRestore()
})
it('is forgotten without waking panes when the worktree is purged', () => {
const store = createTestStore()
seedWorktree(store)
@@ -20,7 +20,6 @@ export function createSetActiveFolderWorkspace(
if (!workspace) {
return
}
clearWorktreeSleepIntent(workspaceKey)
if (shouldDeferActivationTerminalPrep()) {
markInputQuietSchedulerInput()
}
@@ -124,6 +123,8 @@ export function createSetActiveFolderWorkspace(
: s.folderWorkspaces
}
})
// Why: cleared after the set() so a waiting pane connects against the activated state.
clearWorktreeSleepIntent(workspaceKey)
if (workspace.isUnread) {
void get().updateFolderWorkspace(
folderWorkspaceId,