diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 4ba100116c1..9d24a6ae471 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -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 + shutdownWorktreeBrowsers: ReturnType + shutdownWorktreeTerminals: ReturnType + suppressPtyExit: ReturnType + consumeSuppressedPtyExit: ReturnType + tabsByWorktree: Record + ptyIdsByTabId: Record + } = { + 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((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( diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 16ecdc133ea..fd46039ab1c 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts index 8d60aaff49c..c00535ed4b7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-deliberate-sleep-guard.test.ts @@ -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((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 } = diff --git a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts index 2b55854c676..30e1352204d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index aefc798bb87..3d4adba6ee6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -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 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts index 55f4d3e7c0c..e391e35236e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -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() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts index c4e02f82af0..1055d69fe15 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-tab-id.ts @@ -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) | null + tabsByWorktree: Record + 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) + ) +} diff --git a/src/renderer/src/lib/worktree-sleep-intent.ts b/src/renderer/src/lib/worktree-sleep-intent.ts index d7fdfec4b45..5e55c216f1d 100644 --- a/src/renderer/src/lib/worktree-sleep-intent.ts +++ b/src/renderer/src/lib/worktree-sleep-intent.ts @@ -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 }) + } } } diff --git a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts index 08ddf9b6d9c..bbeeb95d34e 100644 --- a/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts +++ b/src/renderer/src/store/slices/worktree-sleep-intent-lifecycle.test.ts @@ -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) diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts index 6585a987574..ed0be9f5e69 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts @@ -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,