diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index f291580db40..afb24b1f779 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -238,41 +238,23 @@ describe('renderer breadcrumb IPC routing', () => { // Why: park-churn notices are per-tab but the ring is process-wide, so many // tabs churning at once would otherwise evict the pre-crash trail. - it('coalesces park-verdict churn notices by trigger across tabs', () => { + it('coalesces park-verdict churn notices by name across tabs', () => { emitRendererBreadcrumb({ name: 'terminal_park_verdict_churn', - data: { tabId: 'tab-1', trigger: 'window', flips: 12, elapsedMs: 8 } + data: { tabId: 'tab-1', flips: 12, elapsedMs: 8 } }) emitRendererBreadcrumb({ name: 'terminal_park_verdict_churn', - data: { tabId: 'tab-2', trigger: 'window', flips: 12, elapsedMs: 9 } + data: { tabId: 'tab-2', flips: 12, elapsedMs: 9 } }) expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledTimes(2) for (const call of recordCoalescedCrashBreadcrumbMock.mock.calls) { - expect(call[0]).toMatchObject({ coalesceKey: 'terminal_park_verdict_churn:window' }) + expect(call[0]).toMatchObject({ coalesceKey: 'terminal_park_verdict_churn' }) } }) - // Why: `burst` means damping engaged a commit short of React #185 while - // `window` is slow benign churn. A shared key would drop the near-crash - // signal into the slow-churn slot. - it('keeps burst and window park-verdict churn triggers in separate slots', () => { - emitRendererBreadcrumb({ - name: 'terminal_park_verdict_churn', - data: { tabId: 'tab-1', trigger: 'burst', flips: 3, elapsedMs: 4 } - }) - emitRendererBreadcrumb({ - name: 'terminal_park_verdict_churn', - data: { tabId: 'tab-1', trigger: 'window', flips: 12, elapsedMs: 900 } - }) - - expect( - recordCoalescedCrashBreadcrumbMock.mock.calls.map((call) => call[0].coalesceKey) - ).toEqual(['terminal_park_verdict_churn:burst', 'terminal_park_verdict_churn:window']) - }) - // Why: every hidden pane is 0x0, so one post-reload reattach wave exhausts // the fit budget once per mounted pane inside ~60ms. Windows crash // F0BKR84AHEH lost 26-90% of its 30-entry ring to two such bursts. diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 2094d0ee1c0..c8405c962ed 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -327,13 +327,12 @@ function buildUncapturedCrashReportText( // erasing the pre-crash trail. Coalesce repeats into one entry that carries a // suppressed count instead. const DUPLICATE_TAB_OWNER_BREADCRUMB = 'terminal_tab_id_owned_by_multiple_worktrees' -const PARK_VERDICT_CHURN_BREADCRUMB = 'terminal_park_verdict_churn' const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ 'renderer_error', 'renderer_unhandled_rejection', + 'terminal_park_verdict_churn', 'terminal_safe_fit_retry_exhausted', DUPLICATE_TAB_OWNER_BREADCRUMB, - PARK_VERDICT_CHURN_BREADCRUMB, TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB ]) const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 @@ -346,7 +345,10 @@ const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 // mounted pane within ~60ms. Windows crash F0BKR84AHEH lost 26-90% of its // 30-entry ring to two such bursts. `suppressedSinceLast` keeps the pane count // — the only signal these carry — in one slot. -const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_safe_fit_retry_exhausted']) +const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set([ + 'terminal_park_verdict_churn', + 'terminal_safe_fit_retry_exhausted' +]) function rendererBreadcrumbCoalesceKey( name: string, @@ -355,14 +357,10 @@ function rendererBreadcrumbCoalesceKey( if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) { return name } - // Why trigger and not name alone: `burst` means damping engaged a commit - // short of React #185, `window` means slow benign churn. Collapsing them - // would drop the near-crash signal into a slow-churn slot. Still bounded — - // two slots per storm regardless of tab count. - if (name === PARK_VERDICT_CHURN_BREADCRUMB) { - return `${name}:${String(data?.trigger ?? '')}` - } - // Preserve distinct GPU failures and atlas-reset triggers while coalescing each storm. + // Why kind and not name alone: a context loss (GPU/driver gave up on this + // renderer) and an atlas reset (routine post-wake repaint) must never + // suppress each other. Within one kind the count is the whole signal — every + // live pane emits on a GPU death. if (name === TERMINAL_WEBGL_DIAGNOSTIC_BREADCRUMB) { const kind = String(data?.kind ?? '') const reason = kind === 'webgl-atlas-reset' ? data?.reason : undefined diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index dc39c3a0644..f569394ae21 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -128,14 +128,27 @@ import { recordTerminalWorktreeParkingDebugVerdicts } from './terminal-pane/terminal-parking-e2e-overrides' import { selectEvictionExemptTerminalTabIds } from './terminal-pane/terminal-eviction-exempt-tabs' +import { selectSleepingRecordParkExemptTabIds } from './terminal-pane/sleeping-record-park-exemption' import { canWatcherCoverParkedTerminalTab, disposeAllParkedTerminalWatchers, + planParkedTerminalTabWatcherCoverage, pruneParkedTerminalWatchers, + subscribeParkedTerminalWatcherOwnershipLoss, shouldDeferParkedPtyExitTabClose, - syncParkedTerminalTabWatchers, - terminalWatcherLiveWorkspaceIds + syncParkedTerminalTabWatchersWithAcknowledgements, + terminalWatcherLiveWorkspaceIds, + type TerminalParkedWatcherCoveragePlan } from './terminal-pane/terminal-parked-tab-watchers' +import { + acknowledgeTerminalParkEpisodeSet, + reconcileTerminalParkEpisodeSet, + selectAcknowledgedTerminalParkEpisodeUnmounts, + selectTerminalParkEpisodeWatcherTabIds, + type TerminalParkEpisodeSet +} from './terminal-pane/terminal-park-episode-set' +import { terminalParkedWatcherPlanPtyIds } from './terminal-pane/terminal-park-episode-lease' +import { createParkedTerminalWatcherTopologyKey } from './terminal-pane/terminal-parked-watcher-reconciliation' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import { setWindowCloseRequestHandler } from './window-close-request-coordinator' @@ -333,6 +346,7 @@ function Terminal(): React.JSX.Element | null { const terminalRetentionBudgetEnabled = useAppStore( (s) => s.settings?.terminalHiddenWorktreeRetentionBudget !== false ) + const sleepingAgentSessionsByPaneKey = useAppStore((s) => s.sleepingAgentSessionsByPaneKey) const browserGuestRetentionBudgetEnabled = useAppStore( (s) => s.settings?.browserGuestWorktreeRetentionBudget !== false ) @@ -420,7 +434,34 @@ function Terminal(): React.JSX.Element | null { : [], [renderedActiveWorktreeId, tabsByWorktree] ) - useTerminalProviderSnapshotCapability(workspaceSessionReady && hydrationSucceeded) + const terminalProviderCapabilityRevision = useTerminalProviderSnapshotCapability( + workspaceSessionReady && hydrationSucceeded + ) + const terminalParkedWatcherLayoutsByTabId = useAppStore( + useShallow((state) => { + const layouts: typeof state.terminalLayoutsByTabId = {} + for (const workspace of workspaceSurfaces) { + for (const tab of tabsByWorktree[workspace.id] ?? []) { + const layout = state.terminalLayoutsByTabId[tab.id] + if (layout) { + layouts[tab.id] = layout + } + } + } + return layouts + }) + ) + const terminalParkedWatcherTopologyKey = useMemo( + () => + JSON.stringify( + workspaceSurfaces.map((workspace) => + createParkedTerminalWatcherTopologyKey(workspace.id, tabsByWorktree[workspace.id] ?? [], { + terminalLayoutsByTabId: terminalParkedWatcherLayoutsByTabId + }) + ) + ), + [tabsByWorktree, terminalParkedWatcherLayoutsByTabId, workspaceSurfaces] + ) // Why: TabBar portals into the titlebar (target created by App.tsx) so tabs share the "Orca" title row. const titlebarTabsTarget = document.getElementById('titlebar-tabs') @@ -800,8 +841,13 @@ function Terminal(): React.JSX.Element | null { }) const effectiveParkedTerminalWorktreeIds = useMemo( () => - combineTerminalWorktreeParkIds(parkedTerminalWorktreeIds, manuallyParkedTerminalWorktreeIds), - [manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds] + terminalParkingEnabled + ? combineTerminalWorktreeParkIds( + parkedTerminalWorktreeIds, + manuallyParkedTerminalWorktreeIds + ) + : new Set(), + [manuallyParkedTerminalWorktreeIds, parkedTerminalWorktreeIds, terminalParkingEnabled] ) // Why separate from parkedTerminalWorktreeIds: force-parked worktrees keep // their eviction-exempt tabs' panes mounted (per-tab exclusion), which @@ -816,6 +862,14 @@ function Terminal(): React.JSX.Element | null { const [evictionExemptTerminalTabIds, setEvictionExemptTerminalTabIds] = useState< ReadonlySet >(() => new Set()) + const legacyParkEpisodeLeasesRef = useRef(new Map()) + const legacyParkPlansByTabIdRef = useRef>( + new Map() + ) + const legacyForcedParkTabIdsRef = useRef>(new Set()) + const [legacyParkedTerminalTabIds, setLegacyParkedTerminalTabIds] = useState>( + () => new Set() + ) // Why a ref: eviction captures buffers exactly once per force-park episode, before the unmount render. const forceParkedCaptureDoneRef = useRef(new Set()) // Tab restriction for targeted background mounts (wake/resume); a worktree absent from this map mounts all its tabs. @@ -824,6 +878,25 @@ function Terminal(): React.JSX.Element | null { const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) // Why: run the cold-activation deferral decision once per activation transition, not on every re-render. const lastActivationWorktreeIdRef = useRef(null) + const revealRejectedActivationDeferredTab = useCallback( + (worktreeId: string, tabId: string): void => { + if (!activationDeferredMountTabIdsByWorktreeRef.current.get(worktreeId)?.has(tabId)) { + return + } + const allTabIds = (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).map( + (tab) => tab.id + ) + revealActivationDeferredTabs({ + restrictions: backgroundMountTabIdsByWorktreeRef.current, + deferredMountTabIdsByWorktree: activationDeferredMountTabIdsByWorktreeRef.current, + worktreeId, + allTabIds, + immediateTabIds: new Set([tabId]) + }) + setBackgroundMountRevision((revision) => revision + 1) + }, + [] + ) useEffect(() => { const timers = measurableBackgroundWorktreeTimersRef.current const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current @@ -986,21 +1059,18 @@ function Terminal(): React.JSX.Element | null { restorePolicy, ...overrides }) - // Why memoized: the retention pass below re-asks coverage for EVERY mounted - // worktree's tabs, not just the parked few — each call re-reads the store and - // can walk the layout tree, so cache per tab for this pass. - const watcherCoverageByTabId = new Map() + const watcherCoveragePlanByTabId = new Map() const worktreeTabsAreWatcherCovered = (worktreeId: string, tabs: TerminalTab[]): boolean => tabs.every((tab) => { - const cached = watcherCoverageByTabId.get(tab.id) - if (cached !== undefined) { - return cached + const cached = watcherCoveragePlanByTabId.get(tab.id) + if (cached) { + return cached.status === 'covered' } - const covered = canWatcherCoverParkedTerminalTab(worktreeId, tab) - watcherCoverageByTabId.set(tab.id, covered) - return covered + const plan = planParkedTerminalTabWatcherCoverage(worktreeId, tab) + watcherCoveragePlanByTabId.set(tab.id, plan) + return plan.status === 'covered' }) - // Why: a worktree with any watcher-uncoverable tab must never park, or it goes silent for bells/titles/completions (sank the first parking attempt). + // Ordinary parking requires a semantic handoff plan for every tab. for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) { if (!worktreeTabsAreWatcherCovered(worktreeId, tabsByWorktree[worktreeId] ?? [])) { nextParkedTerminalWorktreeIds.delete(worktreeId) @@ -1162,7 +1232,9 @@ function Terminal(): React.JSX.Element | null { renderedActiveWorktreeId, tabsByWorktree, terminalParkingEnabled, + terminalParkedWatcherTopologyKey, terminalParkingRevision, + terminalProviderCapabilityRevision, terminalRetentionBudgetEnabled, terminalSshParkingEnabled, workspaceSurfaces @@ -1324,7 +1396,7 @@ function Terminal(): React.JSX.Element | null { isTabDeferrable: (tabId) => { const tab = tabById.get(tabId) return ( - // Why: byte-mode watchers can't reconstruct pre-registration output; remote/unresolved ownership mounts eagerly since only a local daemon has snapshots. + // Why: only authoritative hosts can rebuild pane content beyond the bounded handoff buffer. coldActivationDeferralEnabled && activationHostSupportsDeferral && tab !== undefined && @@ -1389,7 +1461,153 @@ function Terminal(): React.JSX.Element | null { groupsByWorktree, activeGroupIdByWorktree ) - // Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer. + useEffect(() => { + const requestedTabIds = new Set() + const forcedTabIds = new Set() + const requestedTabs: TerminalTab[] = [] + const worktreeIdByTabId = new Map() + if (!anyMountedWorktreeHasLayout && terminalParkingEnabled) { + for (const workspace of workspaceSurfaces) { + if (!mountedWorktreeIdsRef.current.has(workspace.id)) { + continue + } + const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + const shouldMeasure = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) + const worktreePark = + !isVisible && !shouldMeasure && effectiveParkedTerminalWorktreeIds.has(workspace.id) + const forcePark = worktreePark && forceParkedTerminalWorktreeIds.has(workspace.id) + const deferredTabIds = activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) + const sleepingRecordOwnedTabIds = selectSleepingRecordParkExemptTabIds( + sleepingAgentSessionsByPaneKey, + workspace.id + ) + for (const tab of tabsByWorktree[workspace.id] ?? []) { + const portaled = + findActivityTerminalPortal(activityTerminalPortals, { + worktreeId: workspace.id, + tabId: tab.id + }) !== null + const requestedByWorktree = + worktreePark && !portaled && !evictionExemptTerminalTabIds.has(tab.id) + const requestedByDeferral = + deferredTabIds?.has(tab.id) === true && + !portaled && + !sleepingRecordOwnedTabIds.has(tab.id) + if (!requestedByWorktree && !requestedByDeferral) { + continue + } + requestedTabs.push(tab) + requestedTabIds.add(tab.id) + worktreeIdByTabId.set(tab.id, workspace.id) + if (forcePark && requestedByWorktree) { + forcedTabIds.add(tab.id) + } + } + } + } + const reconciled = reconcileTerminalParkEpisodeSet({ + current: legacyParkEpisodeLeasesRef.current, + tabs: requestedTabs, + requestedTabIds, + forcedTabIds, + plan: (tab) => { + const worktreeId = worktreeIdByTabId.get(tab.id) + if (!worktreeId) { + throw new Error(`Missing terminal worktree for ${tab.id}`) + } + return planParkedTerminalTabWatcherCoverage(worktreeId, tab) + } + }) + let nextLeases = reconciled.leases + const acknowledgementRequiredTabIds = new Set() + if (!anyMountedWorktreeHasLayout) { + for (const workspace of workspaceSurfaces) { + const deferredTabIds = + activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null + if ( + !deferredTabIds || + !Array.from(deferredTabIds).some((tabId) => reconciled.unmountTabIds.has(tabId)) + ) { + continue + } + const tabs = tabsByWorktree[workspace.id] ?? [] + const watcherReadyTabIds = new Set( + tabs + .map((tab) => tab.id) + .filter( + (tabId) => + reconciled.unmountTabIds.has(tabId) && + (legacyParkedTerminalTabIds.has(tabId) || deferredTabIds.has(tabId)) + ) + ) + const acknowledgements = syncParkedTerminalTabWatchersWithAcknowledgements({ + worktreeId: workspace.id, + tabs, + parkedTabIds: watcherReadyTabIds, + coveragePlansByTabId: reconciled.plansByTabId, + forcedTabIds, + restoreTitleOnStartTabIds: deferredTabIds + }) + for (const tabId of watcherReadyTabIds) { + if (deferredTabIds.has(tabId)) { + acknowledgementRequiredTabIds.add(tabId) + } + } + nextLeases = acknowledgeTerminalParkEpisodeSet(nextLeases, acknowledgements).leases + } + } + const nextUnmountTabIds = selectAcknowledgedTerminalParkEpisodeUnmounts({ + leases: nextLeases, + plansByTabId: reconciled.plansByTabId, + requestedTabIds, + forcedTabIds, + acknowledgementRequiredTabIds + }) + legacyParkEpisodeLeasesRef.current = nextLeases + legacyParkPlansByTabIdRef.current = reconciled.plansByTabId + legacyForcedParkTabIdsRef.current = forcedTabIds + if (!anyMountedWorktreeHasLayout) { + for (const workspace of workspaceSurfaces) { + for (const tabId of activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? + []) { + if (!nextUnmountTabIds.has(tabId)) { + revealRejectedActivationDeferredTab(workspace.id, tabId) + } + } + } + } + setLegacyParkedTerminalTabIds((current) => + haveSameIdSet(current, nextUnmountTabIds) ? current : nextUnmountTabIds + ) + }, [ + activeTabId, + activeTabIdByWorktree, + activeView, + activityTerminalPortals, + anyMountedWorktreeHasLayout, + backgroundMountRevision, + effectiveParkedTerminalWorktreeIds, + evictionExemptTerminalTabIds, + forceParkedTerminalWorktreeIds, + groupsByWorktree, + legacyParkedTerminalTabIds, + pendingStartupByTabId, + pairedRuntimeParkingEnvironmentIds, + renderedActiveWorktreeId, + revealRejectedActivationDeferredTab, + sleepingAgentSessionsByPaneKey, + tabsByWorktree, + terminalParkedWatcherTopologyKey, + terminalParkingEnabled, + terminalProviderCapabilityRevision, + terminalSshParkingEnabled, + terminalTitleSnapshotAuthorityEnabled, + workspaceSessionReady, + workspaceSurfaces + ]) + + // Legacy owns these watchers; split overlay hooks own worktrees with layouts. useEffect(() => { pruneParkedTerminalWatchers( terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id)) @@ -1403,59 +1621,55 @@ function Terminal(): React.JSX.Element | null { continue } const tabs = tabsByWorktree[workspace.id] ?? [] - const parkedTabIds = new Set() - let deferredTabIds: ReadonlySet | null = null - if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) { - const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId - const shouldMeasureHiddenWorktree = - !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) - const parked = - !isVisible && - !shouldMeasureHiddenWorktree && - effectiveParkedTerminalWorktreeIds.has(workspace.id) - if (parked) { - for (const tab of tabs) { - const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { - worktreeId: workspace.id, - tabId: tab.id - }) - // Why the exempt exclusion: force-park keeps eviction-exempt tabs' - // panes mounted (a remount would orphan their live pty) — same - // per-tab carve-out as Activity portals, so no watcher owns them. - // The set is resolved in the force-park pass and holds only those - // worktrees' tabs. - if (!activityTerminalPortal && !evictionExemptTerminalTabIds.has(tab.id)) { - parkedTabIds.add(tab.id) - } - } - } - // Why: activation-deferred tabs are unmounted like parked ones — the same byte watchers own their side effects until first reveal. - deferredTabIds = - activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null - for (const tab of tabs) { - if ( - deferredTabIds?.has(tab.id) && - !parkedTabIds.has(tab.id) && - canWatcherCoverParkedTerminalTab(workspace.id, tab) && - !findActivityTerminalPortal(activityTerminalPortals, { - worktreeId: workspace.id, - tabId: tab.id - }) - ) { - parkedTabIds.add(tab.id) - } - } - } - syncParkedTerminalTabWatchers({ + const deferredTabIds = + activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null + const parkedTabIds = selectTerminalParkEpisodeWatcherTabIds({ + worktreeId: workspace.id, + tabIds: tabs.map((tab) => tab.id), + leases: legacyParkEpisodeLeasesRef.current, + plansByTabId: legacyParkPlansByTabIdRef.current, + renderedUnmountTabIds: legacyParkedTerminalTabIds, + ...(deferredTabIds ? { activationDeferredTabIds: deferredTabIds } : {}) + }) + const coveragePlansByTabId = new Map( + [...legacyParkPlansByTabIdRef.current].filter(([tabId]) => parkedTabIds.has(tabId)) + ) + const forcedTabIds = new Set( + [...legacyForcedParkTabIdsRef.current].filter((tabId) => parkedTabIds.has(tabId)) + ) + const acknowledgements = syncParkedTerminalTabWatchersWithAcknowledgements({ worktreeId: workspace.id, tabs, parkedTabIds, + coveragePlansByTabId, + forcedTabIds, // Why: activation-deferred tabs never mounted a pane to restore their title, unlike ordinary parked tabs. ...(deferredTabIds ? { restoreTitleOnStartTabIds: deferredTabIds } : {}) }) + const acknowledged = acknowledgeTerminalParkEpisodeSet( + legacyParkEpisodeLeasesRef.current, + acknowledgements + ) + legacyParkEpisodeLeasesRef.current = acknowledged.leases + if (acknowledged.renderChanged) { + const failedTabIds = new Set( + acknowledgements + .filter((acknowledgement) => acknowledgement.status === 'failed') + .map((acknowledgement) => acknowledgement.tabId) + ) + setLegacyParkedTerminalTabIds((current) => { + const next = new Set([...current].filter((tabId) => !failedTabIds.has(tabId))) + return haveSameIdSet(current, next) ? current : next + }) + for (const tabId of failedTabIds) { + if (deferredTabIds?.has(tabId)) { + revealRejectedActivationDeferredTab(workspace.id, tabId) + } + } + } } }, [ - // Why activeTabId: revealing a deferred tab mutates the mount restriction in the same render; sync must re-run so its watcher disposes before the pane attaches. + // Why activeTabId: reveal mutates the mount restriction during render; re-run so current leases release stale watcher ownership in the same effect flush. activeTabId, activeView, activityTerminalPortals, @@ -1465,15 +1679,53 @@ function Terminal(): React.JSX.Element | null { evictionExemptTerminalTabIds, getEffectiveLayoutForWorktree, groupsByWorktree, - effectiveParkedTerminalWorktreeIds, + legacyParkedTerminalTabIds, pendingStartupByTabId, + pairedRuntimeParkingEnvironmentIds, renderedActiveWorktreeId, + revealRejectedActivationDeferredTab, tabsByWorktree, + terminalParkedWatcherTopologyKey, terminalParkingEnabled, + terminalProviderCapabilityRevision, + terminalSshParkingEnabled, terminalTitleSnapshotAuthorityEnabled, workspaceSessionReady, workspaceSurfaces ]) + useEffect( + () => + subscribeParkedTerminalWatcherOwnershipLoss((event) => { + const lease = legacyParkEpisodeLeasesRef.current.get(event.tabId) + if (!lease || lease.phase === 'forced' || lease.plan.worktreeId !== event.worktreeId) { + return + } + const acknowledged = acknowledgeTerminalParkEpisodeSet(legacyParkEpisodeLeasesRef.current, [ + { + status: 'failed', + tabId: event.tabId, + materialKey: lease.plan.materialKey, + reason: 'watcher-ownership-lost', + expectedPtyIds: terminalParkedWatcherPlanPtyIds(lease.plan), + watchedPtyIds: [] + } + ]) + if (!acknowledged.renderChanged) { + return + } + legacyParkEpisodeLeasesRef.current = acknowledged.leases + setLegacyParkedTerminalTabIds((current) => { + if (!current.has(event.tabId)) { + return current + } + const next = new Set(current) + next.delete(event.tabId) + return next + }) + revealRejectedActivationDeferredTab(event.worktreeId, event.tabId) + }), + [revealRejectedActivationDeferredTab] + ) // Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher. useEffect(() => () => disposeAllParkedTerminalWatchers(), []) // Auto-create first tab when worktree activates @@ -2467,6 +2719,7 @@ function Terminal(): React.JSX.Element | null { activationDeferredMountTabIds={ activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null } + onActivationDeferredWatcherHandoffFailed={revealRejectedActivationDeferredTab} /> ) })} @@ -2531,6 +2784,7 @@ function Terminal(): React.JSX.Element | null { // tabs stay mounted because a remount would orphan their live pty. if ( shouldColdParkTerminalPanes && + legacyParkedTerminalTabIds.has(tab.id) && !isActivityPortalTab && !evictionExemptTerminalTabIds.has(tab.id) ) { @@ -2540,6 +2794,7 @@ function Terminal(): React.JSX.Element | null { | null activationDeferredMountTabIds: ReadonlySet | null + onActivationDeferredWatcherHandoffFailed: (worktreeId: string, tabId: string) => void }): React.JSX.Element { const browserPageIds = useAppStore( useShallow((state) => @@ -2752,6 +3009,10 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ const hasMobileDrivenBrowser = useBrowserMobileDriverForAny(browserPageIds) const shouldKeepPaintable = shouldMeasureHiddenWorktree || hasAutomationVisibleBrowser || hasMobileDrivenBrowser + const revealRejectedActivationDeferredTab = useCallback( + (tabId: string) => onActivationDeferredWatcherHandoffFailed(worktreeId, tabId), + [onActivationDeferredWatcherHandoffFailed, worktreeId] + ) return (
{/* Why: once eligible, retain slot DOM so hidden worktrees keep their Electron guests alive (STA-3228). */} | null /** Cold-activation deferred tabs receive immediate parked watcher coverage. */ activationDeferredMountTabIds?: ReadonlySet | null + onActivationDeferredWatcherHandoffFailed?: (tabId: string) => void }): React.JSX.Element | null { const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore( useShallow((state) => ({ @@ -109,7 +111,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ isForceParked, shouldMeasureHiddenWorktree, activityTerminalPortals, - activationDeferredMountTabIds + activationDeferredMountTabIds, + onActivationDeferredWatcherHandoffFailed }) if (!worktreePath) { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher-construction.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher-construction.test.ts new file mode 100644 index 00000000000..f37e3afd80c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher-construction.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ParkedTerminalCommandStatus from './parked-terminal-command-status' + +const PTY_ID = 'pty-construction-failure' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +const commandStatusPolicy = { + onCommandFinished: vi.fn(), + onCommandCodeWorking: vi.fn(), + onCommandCodeDone: vi.fn(), + dispose: vi.fn() +} + +vi.mock('./parked-terminal-command-status', async (importOriginal) => ({ + ...(await importOriginal()), + createParkedTerminalCommandStatusPolicy: vi.fn(() => commandStatusPolicy) +})) + +vi.mock('@/lib/terminal-theme', () => ({ getSystemPrefersDark: () => true })) +vi.mock('./use-notification-dispatch', () => ({ dispatchTerminalNotification: vi.fn() })) + +const storeState = { + settings: { theme: 'system', terminalMainSideEffectAuthority: false }, + agentStatusByPaneKey: {}, + markWorktreeUnread: vi.fn(), + markTerminalTabUnread: vi.fn() +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => storeState } })) + +describe('parked terminal byte watcher construction', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let dispatchData: ((payload: { id: string; data: string }) => void) | null + let setPtyDeliveryInterest: ReturnType + + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + commandStatusPolicy.dispose.mockClear() + storeState.settings.terminalMainSideEffectAuthority = false + storeState.markWorktreeUnread.mockReset() + storeState.markTerminalTabUnread.mockReset() + dispatchData = null + setPtyDeliveryInterest = vi.fn() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + ackData: vi.fn(), + onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { + dispatchData = callback + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + setPtyDeliveryInterest + } + } + } as unknown as typeof window + }) + + afterEach(() => { + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('rolls back partial resources when buffered adoption throws', async () => { + const { ensurePtyDispatcher, ptyDataSidecars } = await import('./pty-dispatcher') + const { drainPreHandlerPtyData } = await import('./pty-pre-handler-buffer') + const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher') + const sendError = new Error('PTY write failed') + ensurePtyDispatcher() + dispatchData?.({ id: PTY_ID, data: '\x1b[?2031h' }) + + expect(() => + startParkedTerminalByteWatcher({ + ptyId: PTY_ID, + tabId: 'tab-1', + worktreeId: 'worktree-1', + leafId: LEAF_ID, + paneId: 1, + sendInput: () => { + throw sendError + } + }) + ).toThrow(sendError) + + expect(commandStatusPolicy.dispose).toHaveBeenCalledOnce() + expect(ptyDataSidecars.has(PTY_ID)).toBe(false) + expect(setPtyDeliveryInterest.mock.calls).toEqual([ + [PTY_ID, true], + [PTY_ID, false] + ]) + const laterPrimary = vi.fn() + drainPreHandlerPtyData(PTY_ID, laterPrimary) + expect(laterPrimary).not.toHaveBeenCalled() + }) + + it('disposes its policy when buffered fact registration throws', async () => { + const { _dispatchTerminalSideEffectBatchForTest, registerTerminalSideEffectFactConsumer } = + await import('./terminal-side-effect-facts-handler') + const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher') + const seed = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: {} }) + seed() + _dispatchTerminalSideEffectBatchForTest({ + ptyId: PTY_ID, + seq: 1, + facts: [{ kind: 'bell' }] + }) + const factError = new Error('fact callback failed') + storeState.settings.terminalMainSideEffectAuthority = true + storeState.markWorktreeUnread.mockImplementation(() => { + throw factError + }) + + expect(() => + startParkedTerminalByteWatcher({ + ptyId: PTY_ID, + tabId: 'tab-1', + worktreeId: 'worktree-1', + leafId: LEAF_ID, + paneId: 1, + sendInput: vi.fn() + }) + ).toThrow(factError) + expect(commandStatusPolicy.dispose).toHaveBeenCalledOnce() + + expect(() => + _dispatchTerminalSideEffectBatchForTest({ + ptyId: PTY_ID, + seq: 2, + facts: [{ kind: 'bell' }] + }) + ).not.toThrow() + const replacementBell = vi.fn() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { onBell: replacementBell } + }) + expect(replacementBell).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index 959a8069db3..814858aff1a 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -175,6 +175,28 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) + it('adopts an OSC title buffered before parked watcher registration', async () => { + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + ensurePtyDispatcher() + emit(WORKING_TITLE_OSC) + + const { dispose } = await startWatcher() + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1) + dispose() + + const { dispose: disposeReplacement } = await startWatcher() + flushSideEffects() + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1) + disposeReplacement() + + emit('\x1b[?2031h') + const next = await startWatcher() + expect(next.sendInput).toHaveBeenCalledExactlyOnceWith('\x1b[?997;1n') + next.dispose() + }) + it('drops the bare cursor-agent native title before it reaches the store', async () => { const { dispose } = await startWatcher() diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index faa9601cbfa..eb2a356256e 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -33,6 +33,7 @@ import { import { dispatchTerminalNotification } from './use-notification-dispatch' import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims' import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { parkedTerminalLeafDrivesTabTitle } from './terminal-parked-title-authority' // Why: keep the live path's BEL-vs-completion race window so notification behavior is identical whether a tab is parked or mounted. const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS @@ -55,7 +56,7 @@ export type ParkedTerminalByteWatcherOptions = { leafId: string /** PaneManager pane id the unmounted pane used; the watcher must write this same slot or a stale "working" title strands. */ paneId: number - /** Whether this PTY's pane was the tab's active split — only the focused split drives the tab title. */ + /** Fallback title authority when the stored layout has no active leaf. */ drivesTabTitle?: boolean /** Last runtime title at park time; seeds the agent tracker so an agent working at unmount still fires completion when it goes idle. */ initialTitle?: string @@ -72,7 +73,7 @@ export function startParkedTerminalByteWatcher( ): () => void { const { ptyId, tabId, worktreeId, paneId, sendInput } = options const remoteRuntimePty = isRemoteRuntimePtyId(ptyId) - const drivesTabTitle = options.drivesTabTitle ?? true + const capturedDrivesTabTitle = options.drivesTabTitle ?? true const paneKey = makePaneKey(tabId, options.leafId) // Why: one watcher per PTY — a stale watcher from a previous park cycle would double-fire bell/completion for the same bytes. @@ -128,7 +129,13 @@ export function startParkedTerminalByteWatcher( const state = useAppStore.getState() wroteRuntimeTitleSlot = true state.setRuntimePaneTitle(tabId, paneId, title) - if (drivesTabTitle) { + if ( + parkedTerminalLeafDrivesTabTitle({ + activeLeafId: state.terminalLayoutsByTabId?.[tabId]?.activeLeafId, + leafId: options.leafId, + capturedAuthority: capturedDrivesTabTitle + }) + ) { state.updateTabTitle(tabId, title) } }, @@ -251,34 +258,9 @@ export function startParkedTerminalByteWatcher( onWorking: commandStatusPolicy.onCommandCodeWorking, onDone: commandStatusPolicy.onCommandCodeDone }) - const unregisterFactConsumer = factSideEffectAuthority - ? registerTerminalSideEffectFactConsumer({ - ptyId, - // Why: ordinary park already has a pane-owned title; the flag below requests a snapshot only when no pane did. - callbacks: { - ...sideEffectCallbacks, - onCommandFinished: commandStatusPolicy.onCommandFinished, - onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking, - onCommandCodeDone: commandStatusPolicy.onCommandCodeDone, - onPrLink: (link) => - useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link), - // Why (gate mode only): the 2031 subscribe arrives as a fact, but the reply stays here — query authority stays with the view/watcher (invariant 6). - ...(factOwnsMode2031 ? { onMode2031Subscribe: sendMode2031Reply } : {}) - }, - // Why: activation-deferred tabs can start a watcher before any pane restored the title; ordinary parked tabs avoid this IPC. - restoreTitleOnRegister: options.restoreTitleOnRegister === true - }) - : null - - // Why: no xterm answers DECSET 2031 while parked; with the gate ON, the responder's sidecar would force-feed bytes to the gated PTY, so skip it. - const stopMode2031Responder = factOwnsMode2031 - ? null - : startParkedTerminalMode2031Responder({ ptyId, sendInput }) - - // Why: parked tabs are the canonical hidden view — mark the PTY gated so main stops renderer byte delivery. - const releaseHiddenDeliveryClaim = hiddenDeliveryGateActive - ? acquireHiddenRendererPtyDeliveryClaim(ptyId) - : null + let unregisterFactConsumer: (() => void) | null = null + let stopMode2031Responder: (() => void) | null = null + let releaseHiddenDeliveryClaim: (() => void) | null = null const processLiveData = (data: string): void => { if (!processor) { @@ -293,10 +275,9 @@ export function startParkedTerminalByteWatcher( } } } - // Why: paired hosts forward derived facts over the one environment event - // stream; parked PTYs never consume terminal multiplex slots or raw bytes. - const unsubscribeByteParsers = - processor === null ? null : subscribeToPtyData(ptyId, processLiveData) + // Why: covered parks restore from an authoritative host snapshot; consuming the + // raw gap prevents duplicate side effects on reveal (force-parks stay best-effort). + let unsubscribeByteParsers: (() => void) | null = null const dispose = (): void => { if (disposed) { @@ -324,6 +305,37 @@ export function startParkedTerminalByteWatcher( parkedWatcherDisposersByPtyId.delete(ptyId) } } + try { + unregisterFactConsumer = factSideEffectAuthority + ? registerTerminalSideEffectFactConsumer({ + ptyId, + callbacks: { + ...sideEffectCallbacks, + onCommandFinished: commandStatusPolicy.onCommandFinished, + onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking, + onCommandCodeDone: commandStatusPolicy.onCommandCodeDone, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link), + ...(factOwnsMode2031 ? { onMode2031Subscribe: sendMode2031Reply } : {}) + }, + restoreTitleOnRegister: options.restoreTitleOnRegister === true + }) + : null + // Why: gated parked PTYs answer mode 2031 from facts, never raw bytes. + stopMode2031Responder = factOwnsMode2031 + ? null + : startParkedTerminalMode2031Responder({ ptyId, sendInput }) + releaseHiddenDeliveryClaim = hiddenDeliveryGateActive + ? acquireHiddenRendererPtyDeliveryClaim(ptyId) + : null + unsubscribeByteParsers = + processor === null + ? null + : subscribeToPtyData(ptyId, processLiveData, { adoptPreHandlerData: true }) + } catch (error) { + dispose() + throw error + } parkedWatcherDisposersByPtyId.set(ptyId, dispose) return dispose } diff --git a/src/renderer/src/components/terminal-pane/pty-data-sidecar-delivery.ts b/src/renderer/src/components/terminal-pane/pty-data-sidecar-delivery.ts new file mode 100644 index 00000000000..a87b21d3e9f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-data-sidecar-delivery.ts @@ -0,0 +1,17 @@ +export type PtyDataSidecarDeliveryFailure = { error: unknown } + +/** Delivers one chunk to a stable cohort and retains its first callback error. */ +export function deliverPtyDataToSidecarCohort( + sidecars: Iterable<(data: string) => void>, + data: string +): PtyDataSidecarDeliveryFailure | null { + let failure: PtyDataSidecarDeliveryFailure | null = null + for (const sidecar of Array.from(sidecars)) { + try { + sidecar(data) + } catch (error) { + failure ??= { error } + } + } + return failure +} diff --git a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts index 3625a8e6007..cd7333ee127 100644 --- a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts +++ b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts @@ -1,9 +1,22 @@ import { acquirePtyDeliveryInterest } from './pty-delivery-interest' -import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher' +import { ensurePtyDispatcher, ptyDataHandlers, ptyDataSidecars } from './pty-dispatcher' +import { clearPreHandlerPtyData, drainPreHandlerPtyData } from './pty-pre-handler-buffer' +import { + deliverPtyDataToSidecarCohort, + type PtyDataSidecarDeliveryFailure +} from './pty-data-sidecar-delivery' + +export type PtyDataSidecarSubscriptionOptions = { + adoptPreHandlerData?: boolean +} /** Register a side-channel data watcher for a PTY without taking ownership * of the primary handler. Returns an unsubscribe fn. */ -export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void { +export function subscribeToPtyData( + ptyId: string, + watcher: (data: string) => void, + options?: PtyDataSidecarSubscriptionOptions +): () => void { ensurePtyDispatcher() // Why: a sidecar is, by definition, a raw-byte consumer — its registration // doubles as the delivery-interest signal that suppresses main's @@ -14,16 +27,44 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi set = new Set() ptyDataSidecars.set(ptyId, set) } - set.add(watcher) - return () => { + const sidecar = options?.adoptPreHandlerData + ? (data: string): void => { + if (!ptyDataSidecars.get(ptyId)?.has(sidecar)) { + return + } + if (!ptyDataHandlers.has(ptyId)) { + clearPreHandlerPtyData(ptyId) + } + watcher(data) + } + : watcher + set.add(sidecar) + const unsubscribe = (): void => { releaseDeliveryInterest() const current = ptyDataSidecars.get(ptyId) if (!current) { return } - current.delete(watcher) + current.delete(sidecar) if (current.size === 0) { ptyDataSidecars.delete(ptyId) } } + try { + if (options?.adoptPreHandlerData && !ptyDataHandlers.has(ptyId)) { + const adoption: { failure: PtyDataSidecarDeliveryFailure | null } = { failure: null } + drainPreHandlerPtyData(ptyId, (data) => { + const sidecars = ptyDataSidecars.get(ptyId) + const chunkFailure = sidecars ? deliverPtyDataToSidecarCohort(sidecars, data) : null + adoption.failure ??= chunkFailure + }) + if (adoption.failure) { + throw adoption.failure.error + } + } + } catch (error) { + unsubscribe() + throw error + } + return unsubscribe } diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 73f756c0857..dc3319fa91a 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -14,6 +14,12 @@ import { drainPreHandlerPtyExit } from './pty-pre-handler-buffer' import { deliverPtyExitToHandlers } from './pty-exit-delivery' +import { + ptyExitSidecars, + registerPtyExitSidecar, + type PtyExitSubscriptionOptions, + type PtyExitWatcher +} from './pty-exit-sidecar-subscriptions' import { clearReceivedPtyCharTotal, isPtyPushDeliveryBlackholed, @@ -32,6 +38,7 @@ import { ptyReplayHandlers } from './pty-shutdown-data-suspension' import { markCommittedPtyShutdowns } from './pty-shutdown-exit-deferral' +import { deliverPtyDataToSidecarCohort } from './pty-data-sidecar-delivery' export { ptyDataHandlers, @@ -60,10 +67,6 @@ export type PtyDataMeta = { /** Sidecar PTY-data observers, invoked AFTER the primary handler so a side-effect-only watcher can't delay xterm rendering. */ /** Per-PTY replay handlers on a dedicated pty:replay channel so the renderer can engage the replay guard and suppress xterm auto-replies. */ -const ptyExitSidecars = new Map< - string, - Set<(code: number, context: { hadPrimary: boolean }) => void> ->() export const ptyWriteUnavailableHandlers = new Map void>() let ptyDispatcherAttached = false @@ -155,10 +158,9 @@ function handleDispatchedPtyData(payload: { } const sidecars = ptyDataSidecars.get(payload.id) if (sidecars && sidecars.size > 0) { - // Why: snapshot before iterating — watchers often unsubscribe (or subscribe siblings) mid-iteration, and mutating the live Set would skip or double-fire. - const snapshot = Array.from(sidecars) - for (const watcher of snapshot) { - watcher(payload.data) + const failure = deliverPtyDataToSidecarCohort(sidecars, payload.data) + if (failure) { + throw failure.error } } } @@ -222,27 +224,15 @@ function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { window.api.pty.rendererDispatcherReady?.() } +export type { PtyExitSubscriptionOptions } from './pty-exit-sidecar-subscriptions' + export function subscribeToPtyExit( ptyId: string, - watcher: (code: number, context: { hadPrimary: boolean }) => void + watcher: PtyExitWatcher, + options?: PtyExitSubscriptionOptions ): () => void { ensurePtyDispatcher() - let set = ptyExitSidecars.get(ptyId) - if (!set) { - set = new Set() - ptyExitSidecars.set(ptyId, set) - } - set.add(watcher) - return () => { - const current = ptyExitSidecars.get(ptyId) - if (!current) { - return - } - current.delete(watcher) - if (current.size === 0) { - ptyExitSidecars.delete(ptyId) - } - } + return registerPtyExitSidecar(ptyId, watcher, options) } // ─── Eager PTY buffer for reconnection on restart ──────────────────── diff --git a/src/renderer/src/components/terminal-pane/pty-exit-sidecar-subscriptions.ts b/src/renderer/src/components/terminal-pane/pty-exit-sidecar-subscriptions.ts new file mode 100644 index 00000000000..7eae47afdad --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-exit-sidecar-subscriptions.ts @@ -0,0 +1,44 @@ +import { drainPreHandlerPtyExit, hasPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { ptyDataHandlers, ptyExitHandlers } from './pty-shutdown-data-suspension' + +export type PtyExitWatcher = (code: number, context: { hadPrimary: boolean }) => void +export type PtyExitSubscriptionOptions = { + adoptPreHandlerExit?: boolean +} + +export const ptyExitSidecars = new Map>() + +export function registerPtyExitSidecar( + ptyId: string, + watcher: PtyExitWatcher, + options?: PtyExitSubscriptionOptions +): () => void { + let set = ptyExitSidecars.get(ptyId) + if (!set) { + set = new Set() + ptyExitSidecars.set(ptyId, set) + } + set.add(watcher) + const unsubscribe = (): void => { + const current = ptyExitSidecars.get(ptyId) + if (!current) { + return + } + current.delete(watcher) + if (current.size === 0) { + ptyExitSidecars.delete(ptyId) + } + } + if (options?.adoptPreHandlerExit && hasPreHandlerPtyExit(ptyId)) { + // Why: data-derived effects use 0ms timers; FIFO keeps final output ahead of exit cleanup. + setTimeout(() => { + const current = ptyExitSidecars.get(ptyId) + if (!current?.has(watcher) || ptyExitHandlers.has(ptyId) || ptyDataHandlers.has(ptyId)) { + return + } + unsubscribe() + drainPreHandlerPtyExit(ptyId, (code) => watcher(code, { hadPrimary: false })) + }, 0) + } + return unsubscribe +} diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts index 461e95edfed..e219c26b20d 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.test.ts @@ -3,6 +3,7 @@ import { bufferPreHandlerPtyData, bufferPreHandlerPtyExit, clearConsumedPreHandlerPtyExit, + clearPreHandlerPtyData, clearPreHandlerPtyState, consumePreHandlerPtyState, drainPreHandlerPtyData, @@ -125,6 +126,20 @@ describe('pre-handler PTY buffer', () => { expect(hasPreHandlerPtyExit(EXIT_PTY_ID)).toBe(false) }) + it('clears adopted data without discarding a buffered exit', () => { + bufferPreHandlerPtyData(EXIT_PTY_ID, 'final output') + bufferPreHandlerPtyExit(EXIT_PTY_ID, 7) + + clearPreHandlerPtyData(EXIT_PTY_ID) + + const data = vi.fn() + const exit = vi.fn() + drainPreHandlerPtyData(EXIT_PTY_ID, data) + drainPreHandlerPtyExit(EXIT_PTY_ID, exit) + expect(data).not.toHaveBeenCalled() + expect(exit).toHaveBeenCalledWith(7) + }) + it('discards delayed data and exit until an explicit reconnect', () => { discardPreHandlerPtyState(EXIT_PTY_ID) bufferPreHandlerPtyData(EXIT_PTY_ID, 'late data') diff --git a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts index eca3b7644b9..87b01dc2053 100644 --- a/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-pre-handler-buffer.ts @@ -176,8 +176,13 @@ export function drainPreHandlerPtyExit(ptyId: string, handler: (code: number) => } } -export function clearPreHandlerPtyState(ptyId: string): void { +export function clearPreHandlerPtyData(ptyId: string): void { preHandlerPtyData.delete(ptyId) + warnedLostHandlerPtyIds.delete(ptyId) +} + +export function clearPreHandlerPtyState(ptyId: string): void { + clearPreHandlerPtyData(ptyId) preHandlerPtyExit.delete(ptyId) consumedPreHandlerPtyExits.delete(ptyId) const discardTimer = discardedPreHandlerPtyStates.get(ptyId) @@ -185,5 +190,4 @@ export function clearPreHandlerPtyState(ptyId: string): void { clearTimeout(discardTimer) } discardedPreHandlerPtyStates.delete(ptyId) - warnedLostHandlerPtyIds.delete(ptyId) } diff --git a/src/renderer/src/components/terminal-pane/pty-sidecar-pre-handler-adoption.test.ts b/src/renderer/src/components/terminal-pane/pty-sidecar-pre-handler-adoption.test.ts new file mode 100644 index 00000000000..23ec26674d0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-sidecar-pre-handler-adoption.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type DataPayload = { id: string; data: string; rawLength?: number } +type ExitPayload = { id: string; code: number } + +function flushTimers(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('PTY sidecar pre-handler adoption', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let dispatchData: ((payload: DataPayload) => void) | null + let dispatchExit: ((payload: ExitPayload) => void) | null + + beforeEach(() => { + vi.resetModules() + dispatchData = null + dispatchExit = null + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + ackData: vi.fn(), + rendererDispatcherReady: vi.fn(), + onData: vi.fn((callback: (payload: DataPayload) => void) => { + dispatchData = callback + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((callback: (payload: ExitPayload) => void) => { + dispatchExit = callback + return () => {} + }) + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('adopts buffered data through the current sidecar cohort exactly once', async () => { + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + const { drainPreHandlerPtyData } = await import('./pty-pre-handler-buffer') + const responder = vi.fn() + const adopter = vi.fn() + + ensurePtyDispatcher() + dispatchData?.({ id: 'pty-adopt-data', data: 'gap' }) + const unsubscribeResponder = subscribeToPtyData('pty-adopt-data', responder) + const unsubscribeAdopter = subscribeToPtyData('pty-adopt-data', adopter, { + adoptPreHandlerData: true + }) + dispatchData?.({ id: 'pty-adopt-data', data: 'live' }) + + expect(responder.mock.calls).toEqual([['gap'], ['live']]) + expect(adopter.mock.calls).toEqual([['gap'], ['live']]) + unsubscribeAdopter() + const replacement = vi.fn() + const unsubscribeReplacement = subscribeToPtyData('pty-adopt-data', replacement, { + adoptPreHandlerData: true + }) + expect(replacement).not.toHaveBeenCalled() + const laterPrimary = vi.fn() + drainPreHandlerPtyData('pty-adopt-data', laterPrimary) + expect(laterPrimary).not.toHaveBeenCalled() + + unsubscribeReplacement() + unsubscribeResponder() + }) + + it('attempts every buffered chunk for every sidecar before rethrowing', async () => { + const { ensurePtyDispatcher, ptyDataSidecars } = await import('./pty-dispatcher') + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + const { drainPreHandlerPtyData } = await import('./pty-pre-handler-buffer') + const firstError = new Error('first sidecar failed') + const throwingPeer = vi.fn((data: string) => { + if (data === 'gap-1') { + throw firstError + } + }) + const peer = vi.fn() + const adopter = vi.fn((data: string) => { + if (data === 'gap-1') { + throw new Error('adopter failed') + } + }) + + ensurePtyDispatcher() + dispatchData?.({ id: 'pty-adopt-error', data: 'gap-1' }) + dispatchData?.({ id: 'pty-adopt-error', data: 'gap-2' }) + const unsubscribeThrowingPeer = subscribeToPtyData('pty-adopt-error', throwingPeer) + const unsubscribePeer = subscribeToPtyData('pty-adopt-error', peer) + + expect(() => + subscribeToPtyData('pty-adopt-error', adopter, { adoptPreHandlerData: true }) + ).toThrow(firstError) + expect(throwingPeer.mock.calls).toEqual([['gap-1'], ['gap-2']]) + expect(peer.mock.calls).toEqual([['gap-1'], ['gap-2']]) + expect(adopter.mock.calls).toEqual([['gap-1'], ['gap-2']]) + expect(ptyDataSidecars.get('pty-adopt-error')?.size).toBe(2) + const laterPrimary = vi.fn() + drainPreHandlerPtyData('pty-adopt-error', laterPrimary) + expect(laterPrimary).not.toHaveBeenCalled() + + unsubscribePeer() + unsubscribeThrowingPeer() + }) + + it('leaves buffered data untouched when no sidecar opts into adoption', async () => { + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + const { drainPreHandlerPtyData } = await import('./pty-pre-handler-buffer') + const watcher = vi.fn() + + ensurePtyDispatcher() + dispatchData?.({ id: 'pty-default-sidecar', data: 'gap' }) + const unsubscribe = subscribeToPtyData('pty-default-sidecar', watcher) + const primary = vi.fn() + drainPreHandlerPtyData('pty-default-sidecar', primary) + + expect(watcher).not.toHaveBeenCalled() + expect(primary).toHaveBeenCalledWith('gap', undefined) + unsubscribe() + }) + + it('delivers live data to every sidecar before rethrowing the first error', async () => { + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + const firstError = new Error('first live sidecar failed') + const throwingWatcher = vi.fn(() => { + throw firstError + }) + const laterWatcher = vi.fn() + + ensurePtyDispatcher() + const unsubscribeThrowing = subscribeToPtyData('pty-live-error', throwingWatcher) + const unsubscribeLater = subscribeToPtyData('pty-live-error', laterWatcher) + expect(() => dispatchData?.({ id: 'pty-live-error', data: 'live' })).toThrow(firstError) + expect(throwingWatcher).toHaveBeenCalledWith('live') + expect(laterWatcher).toHaveBeenCalledWith('live') + + unsubscribeLater() + unsubscribeThrowing() + }) + + it('delivers adopted data effects before its buffered exit', async () => { + const { ensurePtyDispatcher, subscribeToPtyExit } = await import('./pty-dispatcher') + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + const events: string[] = [] + const exitWatcher = vi.fn(() => events.push('exit')) + + ensurePtyDispatcher() + dispatchData?.({ id: 'pty-data-before-exit', data: 'final frame' }) + dispatchExit?.({ id: 'pty-data-before-exit', code: 17 }) + subscribeToPtyData( + 'pty-data-before-exit', + () => setTimeout(() => events.push('data-effect'), 0), + { adoptPreHandlerData: true } + ) + subscribeToPtyExit('pty-data-before-exit', exitWatcher, { adoptPreHandlerExit: true }) + + expect(events).toEqual([]) + await flushTimers() + expect(events).toEqual(['data-effect', 'exit']) + expect(exitWatcher).toHaveBeenCalledWith(17, { hadPrimary: false }) + }) + + it('preserves buffered exits when the adopter leaves or a primary registers', async () => { + const { ensurePtyDispatcher, ptyDataHandlers, ptyExitHandlers, subscribeToPtyExit } = + await import('./pty-dispatcher') + const { drainPreHandlerPtyExit } = await import('./pty-pre-handler-buffer') + const departedWatcher = vi.fn() + const remountWatcher = vi.fn() + + ensurePtyDispatcher() + dispatchExit?.({ id: 'pty-departed-adopter', code: 18 }) + const unsubscribeDeparted = subscribeToPtyExit('pty-departed-adopter', departedWatcher, { + adoptPreHandlerExit: true + }) + unsubscribeDeparted() + dispatchExit?.({ id: 'pty-remount-wins', code: 19 }) + const unsubscribeRemount = subscribeToPtyExit('pty-remount-wins', remountWatcher, { + adoptPreHandlerExit: true + }) + ptyDataHandlers.set('pty-remount-wins', vi.fn()) + const primaryExit = vi.fn() + ptyExitHandlers.set('pty-remount-wins', primaryExit) + + await flushTimers() + const departedOwner = vi.fn() + drainPreHandlerPtyExit('pty-departed-adopter', departedOwner) + drainPreHandlerPtyExit('pty-remount-wins', primaryExit) + expect(departedWatcher).not.toHaveBeenCalled() + expect(remountWatcher).not.toHaveBeenCalled() + expect(departedOwner).toHaveBeenCalledWith(18) + expect(primaryExit).toHaveBeenCalledWith(19) + + unsubscribeRemount() + ptyDataHandlers.delete('pty-remount-wins') + ptyExitHandlers.delete('pty-remount-wins') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts index 51a0716c13b..b5692be08b1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.test.ts @@ -169,45 +169,4 @@ describe('getTerminalTabColdParkRecheckDelayMs', () => { }) ).toBe(250) }) - - // Why: a flip-damped tab is long past every hysteresis deadline, so the pin - // expiry is the only wakeup left — drop it and the tab never re-parks. - it('wakes at the flip-damping pin deadline when all ordinary deadlines have passed', () => { - expect( - getTerminalTabColdParkRecheckDelayMs({ - parkingEnabled: true, - hiddenSinceMs: 1_000, - nowMs: 1_000_000, - coldParkDelayMs: 100, - hotRetainMs: 1_000, - parkVerdictPinUntilMs: 1_060_000 - }) - ).toBe(60_000) - }) - - // Why: the earliest pending deadline wins — a pin must not delay a nearer - // cool-down wakeup, and vice versa. - it('takes the nearest of the pin and cool-down deadlines', () => { - const base = { - parkingEnabled: true, - hiddenSinceMs: 1_000, - nowMs: 2_500, - coldParkDelayMs: 100, - hotRetainMs: 1_000 - } - expect( - getTerminalTabColdParkRecheckDelayMs({ - ...base, - parkCooldownUntilMs: 2_750, - parkVerdictPinUntilMs: 62_500 - }) - ).toBe(250) - expect( - getTerminalTabColdParkRecheckDelayMs({ - ...base, - parkCooldownUntilMs: 62_500, - parkVerdictPinUntilMs: 2_750 - }) - ).toBe(250) - }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts index c2ddc024119..c32bcd4fbb0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-recheck-deadlines.ts @@ -1,8 +1,4 @@ -/** - * Why: parking decisions change only at the cold-park, hot-retain, retention - * TTL, and post-measure cool-down deadlines, so the verdict effects schedule - * one recheck at the next deadline instead of polling. - */ +/** Computes the next policy deadline without polling. */ import { TERMINAL_TAB_COLD_PARK_DELAY_MS, TERMINAL_TAB_HOT_RETAIN_MS, @@ -18,7 +14,6 @@ function nextColdParkDeadlineDelayMs(args: { hotRetainMs: number retentionTtlMs?: number parkCooldownUntilMs?: number | null - parkVerdictPinUntilMs?: number | null }): number | null { if (!args.parkingEnabled || args.hiddenSinceMs === null) { return null @@ -27,10 +22,7 @@ function nextColdParkDeadlineDelayMs(args: { args.hiddenSinceMs + args.coldParkDelayMs, args.hiddenSinceMs + args.hotRetainMs, ...(args.retentionTtlMs !== undefined ? [args.hiddenSinceMs + args.retentionTtlMs] : []), - // Why: the cool-down holds past-deadline candidates out of the parked set; without this wakeup nothing re-parks them. - ...(args.parkCooldownUntilMs != null ? [args.parkCooldownUntilMs] : []), - // Why: damping stops the churn that would otherwise wake this deadline. - ...(args.parkVerdictPinUntilMs != null ? [args.parkVerdictPinUntilMs] : []) + ...(args.parkCooldownUntilMs != null ? [args.parkCooldownUntilMs] : []) ].filter((deadlineMs) => deadlineMs > args.nowMs) return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs } @@ -41,7 +33,6 @@ export function getTerminalWorktreeColdParkRecheckDelayMs(args: { nowMs: number coldParkDelayMs?: number hotRetainMs?: number - /** Provided only for retention-budget candidates so their TTL wakes the verdict effect. */ retentionTtlMs?: number parkCooldownUntilMs?: number | null }): number | null { @@ -63,8 +54,6 @@ export function getTerminalTabColdParkRecheckDelayMs(args: { coldParkDelayMs?: number hotRetainMs?: number parkCooldownUntilMs?: number | null - /** Provided only for tabs damped out of the parked set by flip churn. */ - parkVerdictPinUntilMs?: number | null }): number | null { return nextColdParkDeadlineDelayMs({ parkingEnabled: args.parkingEnabled, @@ -72,7 +61,6 @@ export function getTerminalTabColdParkRecheckDelayMs(args: { nowMs: args.nowMs, coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS, - parkCooldownUntilMs: args.parkCooldownUntilMs, - parkVerdictPinUntilMs: args.parkVerdictPinUntilMs + parkCooldownUntilMs: args.parkCooldownUntilMs }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx index 97cb2db3428..2bb8c1f7e2e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx @@ -1,20 +1,5 @@ /** @vitest-environment happy-dom */ -/** - * Cold-park verdict oscillation (crash cluster: React #185 in the terminal overlay). - * - * Why this shape: canWatcherCoverParkedTerminalTab is re-derived from store and - * registry state that mounting/unmounting the very pane the verdict controls - * rewrites, and the parked watchers write the tab model back. The pane stand-in - * models that edge — mounted pane grants coverage, parked pane withdraws it, - * and either transition re-mints the tab array the cold-park effect depends on. - * - * Why the store-write chain is parameterised: React bails on *commits*, not on - * flips, so a damping threshold expressed in flips is only safe if it holds at - * the real cycle's commits-per-flip cost (pane-connect updateTabPtyId, the - * watcher-sync writes, the overlay's own subscriber renders). The chain models - * that cost so the derived burst limit stays regression-tested. - */ -import { act, useEffect, useRef, useState } from 'react' +import { act, useEffect } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalTab } from '../../../../shared/types' @@ -23,8 +8,11 @@ import type { TerminalTab } from '../../../../shared/types' const park = vi.hoisted(() => ({ worktreeId: 'repo::/wt-park-loop', - /** Whether the byte watchers could cover the tab if it parked right now. */ - coverage: true + materialRevision: 0, + captureSource: 'fallback' as 'fallback' | 'capture', + failNextHandoff: false, + syncedParked: new Set(), + observedSources: [] as string[] })) vi.mock('../../store', async () => { @@ -35,16 +23,83 @@ vi.mock('../../store', async () => { settings: {} as Record, terminalLayoutsByTabId: {} as Record, runtimePaneTitlesByTabId: {} as Record, + sleepingAgentSessionsByPaneKey: {} as Record, tabsByWorktree: {} as Record })) return { useAppStore } }) vi.mock('./terminal-parked-tab-watchers', () => ({ - canWatcherCoverParkedTerminalTab: () => park.coverage, disposeParkedTerminalWatchersForWorktree: () => {}, - resolveParkedTerminalPaneCandidates: () => [], - syncParkedTerminalTabWatchers: () => {} + planParkedTerminalTabWatcherCoverage: (worktreeId: string, tab: TerminalTab) => { + park.observedSources.push(park.captureSource) + return { + status: 'covered', + materialKey: `${worktreeId}:${tab.id}:${tab.ptyId}:${park.materialRevision}`, + worktreeId, + tabId: tab.id, + tabPtyId: tab.ptyId, + generation: tab.generation ?? null, + panes: [ + { + leafId: '11111111-1111-4111-8111-111111111111', + ptyId: tab.ptyId + } + ] + } + }, + subscribeParkedTerminalWatcherOwnershipLoss: () => () => {}, + syncParkedTerminalTabWatchersWithAcknowledgements: (args: { + parkedTabIds: ReadonlySet + forcedTabIds: ReadonlySet + coveragePlansByTabId: ReadonlyMap< + string, + { materialKey: string; panes: readonly { ptyId: string | null }[] } + > + }) => { + const acknowledgements: object[] = [] + for (const tabId of args.parkedTabIds) { + const plan = args.coveragePlansByTabId.get(tabId) + const watchedPtyIds = (plan?.panes ?? []).flatMap((pane) => + pane.ptyId === null ? [] : [pane.ptyId] + ) + if (!park.syncedParked.has(tabId)) { + park.syncedParked.add(tabId) + rewriteTabModel(tabId) + } + if (args.forcedTabIds.has(tabId)) { + acknowledgements.push({ + status: 'forced', + tabId, + materialKey: plan?.materialKey ?? null, + watchedPtyIds + }) + } else if (park.failNextHandoff && tabId === PARKABLE_TAB_ID) { + park.failNextHandoff = false + acknowledgements.push({ + status: 'failed', + tabId, + materialKey: plan?.materialKey ?? null, + reason: 'watcher-coverage-incomplete', + expectedPtyIds: watchedPtyIds, + watchedPtyIds: [] + }) + } else { + acknowledgements.push({ + status: 'covering', + tabId, + materialKey: plan?.materialKey ?? '', + watchedPtyIds + }) + } + } + for (const tabId of park.syncedParked) { + if (!args.parkedTabIds.has(tabId)) { + park.syncedParked.delete(tabId) + } + } + return acknowledgements + } })) vi.mock('./terminal-parking-e2e-overrides', () => ({ @@ -52,20 +107,10 @@ vi.mock('./terminal-parking-e2e-overrides', () => ({ })) import { useAppStore } from '../../store' -import { - TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - TERMINAL_TAB_PARK_FLIP_COMMIT_COST -} from './terminal-park-verdict-flip-telemetry' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' -/** The most recently hidden tab holds the last-active retain exemption. */ const EXEMPT_TAB_ID = 'tab-a' const PARKABLE_TAB_ID = 'tab-b' -/** Stops an unfixed loop from hanging the run if React ever raises its bail. */ -const RENDER_HARD_STOP = 500 -/** +1: the pin lands in the passive effect observing the burst flip, so the - * verdict settles one flip after damping engages. */ -const SETTLED_FLIP_BUDGET = TERMINAL_TAB_PARK_FLIP_BURST_LIMIT + 1 const EMPTY_ASSIGNMENTS = new Map() const EMPTY_PORTALS: never[] = [] @@ -81,70 +126,37 @@ function terminalTab(id: string): TerminalTab { let tabModelRevision = 0 -// Models the store writes a pane mount/unmount produces — updateTabPtyId on -// connect, parked-watcher title writes — both of which re-mint the tab array. function rewriteTabModel(tabId: string): void { tabModelRevision += 1 - const revision = tabModelRevision parkingStore.setState((state) => ({ tabsByWorktree: { ...state.tabsByWorktree, [park.worktreeId]: (state.tabsByWorktree[park.worktreeId] ?? []).map((tab) => - tab.id === tabId ? { ...tab, title: `${tab.id}-${revision}` } : tab + tab.id === tabId ? { ...tab, title: `${tab.id}-${tabModelRevision}` } : tab ) } })) } -/** Store writes a single park transition costs; each lands in its own commit. */ -let storeCommitsPerParkTransition = 1 let paneMountCount = 0 -/** The pane whose mount/unmount the park verdict authorizes. */ function TerminalPaneStandIn(): null { useEffect(() => { paneMountCount += 1 + park.captureSource = 'fallback' + return () => { + park.captureSource = 'capture' + } }, []) return null } -/** - * The store writes a park transition drags behind it — pane-connect - * updateTabPtyId, then the watcher-sync writes that each only see the tab model - * the previous write produced. Coverage is re-derived from the last of them, so - * the verdict cannot flip until the whole chain has committed. Why staged - * rather than batched: nested commits are what React counts against - * NESTED_UPDATE_LIMIT, so this is the per-flip cost the damping must beat. - */ -function ParkTransitionStoreWrites({ parked }: { parked: boolean }): null { - const pendingWritesRef = useRef(storeCommitsPerParkTransition) - const lastParkedRef = useRef(parked) - const [writeTick, setWriteTick] = useState(0) - useEffect(() => { - if (lastParkedRef.current !== parked) { - lastParkedRef.current = parked - pendingWritesRef.current = storeCommitsPerParkTransition - } - if (pendingWritesRef.current <= 0) { - return - } - pendingWritesRef.current -= 1 - if (pendingWritesRef.current === 0) { - // A mounted pane grants byte-watcher coverage; a parked one withdraws it. - park.coverage = !parked - } - rewriteTabModel(PARKABLE_TAB_ID) - setWriteTick((tick) => tick + 1) - }, [parked, writeTick]) - return null -} - -let hostRenderCount = 0 +let worktreeParkRequested = false +let shouldMeasureHiddenWorktree = false let parkVerdictFlipCount = 0 let lastParkVerdict = false function OverlayHost(): React.JSX.Element | null { - hostRenderCount += 1 const terminalTabs = useAppStore( (state) => (state as ParkingStoreState).tabsByWorktree[park.worktreeId] ) as TerminalTab[] @@ -153,8 +165,8 @@ function OverlayHost(): React.JSX.Element | null { terminalTabs, assignments: EMPTY_ASSIGNMENTS, isWorktreeActive: false, - coldParkTerminalPanes: false, - shouldMeasureHiddenWorktree: false, + coldParkTerminalPanes: worktreeParkRequested, + shouldMeasureHiddenWorktree, activityTerminalPortals: EMPTY_PORTALS, activationDeferredMountTabIds: null }) @@ -163,24 +175,14 @@ function OverlayHost(): React.JSX.Element | null { parkVerdictFlipCount += 1 } lastParkVerdict = parked - if (hostRenderCount > RENDER_HARD_STOP) { - return null - } - return ( - <> - {parked ? null : } - - - ) + return parked ? null : } function renderOverlayHost(root: Root): unknown { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) let thrown: unknown = null try { - act(() => { - root.render() - }) + act(() => root.render()) } catch (error) { thrown = error } @@ -188,19 +190,22 @@ function renderOverlayHost(root: Root): unknown { return thrown } -describe('cold-park verdict oscillation', () => { +describe('cold-park episode stability', () => { let container: HTMLDivElement - // Why optional: a throwing createRoot must not be masked by an afterEach TypeError. let root: Root | undefined beforeEach(() => { - park.coverage = true - hostRenderCount = 0 - parkVerdictFlipCount = 0 - lastParkVerdict = false + park.materialRevision = 0 + park.captureSource = 'fallback' + park.failNextHandoff = false + park.syncedParked.clear() + park.observedSources.length = 0 tabModelRevision = 0 paneMountCount = 0 - storeCommitsPerParkTransition = 1 + worktreeParkRequested = false + shouldMeasureHiddenWorktree = false + parkVerdictFlipCount = 0 + lastParkVerdict = false parkingStore.setState(() => ({ tabsByWorktree: { [park.worktreeId]: [terminalTab(EXEMPT_TAB_ID), terminalTab(PARKABLE_TAB_ID)] @@ -211,37 +216,46 @@ describe('cold-park verdict oscillation', () => { }) afterEach(() => { - act(() => { - root?.unmount() - }) + act(() => root?.unmount()) root = undefined container.remove() }) - it('settles the park verdict when watcher coverage tracks the pane it unmounts', () => { + it('keeps one verdict across title writes and capture-source changes', () => { root = createRoot(container) - const thrown = renderOverlayHost(root) - // Damping must land inside the burst window and settle on the safe side: - // the pane stays mounted, so bells/titles/completions never go silent. - expect(thrown).toBeNull() - expect(parkVerdictFlipCount).toBeLessThanOrEqual(SETTLED_FLIP_BUDGET) - expect(lastParkVerdict).toBe(false) - expect(paneMountCount).toBeGreaterThan(0) + expect(renderOverlayHost(root)).toBeNull() + expect(lastParkVerdict).toBe(true) + expect(parkVerdictFlipCount).toBe(1) + expect(paneMountCount).toBe(1) + expect(new Set(park.observedSources)).toEqual(new Set(['fallback', 'capture'])) }) - // Why: the burst limit is derived from React's 50-commit bail divided by an - // assumed worst-case commits-per-flip. Running the loop at exactly that cost - // is what keeps the derivation honest — a limit tuned on a one-write-per-flip - // model pins only after React has already thrown #185. - it('settles at the assumed worst-case commit cost per park transition', () => { - storeCommitsPerParkTransition = TERMINAL_TAB_PARK_FLIP_COMMIT_COST + it('remounts once after a failed worktree handoff and re-arms on a new plan', () => { + shouldMeasureHiddenWorktree = true root = createRoot(container) - const thrown = renderOverlayHost(root) + expect(renderOverlayHost(root)).toBeNull() + expect(paneMountCount).toBe(1) - expect(thrown).toBeNull() - expect(parkVerdictFlipCount).toBeLessThanOrEqual(SETTLED_FLIP_BUDGET) + shouldMeasureHiddenWorktree = false + worktreeParkRequested = true + park.failNextHandoff = true + expect(renderOverlayHost(root)).toBeNull() expect(lastParkVerdict).toBe(false) - expect(paneMountCount).toBeGreaterThan(0) + expect(parkVerdictFlipCount).toBe(2) + expect(paneMountCount).toBe(2) + + act(() => { + for (let revision = 0; revision < 20; revision += 1) { + rewriteTabModel(PARKABLE_TAB_ID) + } + }) + expect(lastParkVerdict).toBe(false) + expect(paneMountCount).toBe(2) + + park.materialRevision += 1 + act(() => rewriteTabModel(PARKABLE_TAB_ID)) + expect(lastParkVerdict).toBe(true) + expect(parkVerdictFlipCount).toBe(3) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts deleted file mode 100644 index a72867e3c22..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalTab } from '../../../../shared/types' -import type { ParkVerdictFlipRecord } from './terminal-park-verdict-flip-telemetry' -import { withholdUnparkableTerminalTabs } from './terminal-cold-park-withheld-tabs' - -const coverage = vi.hoisted(() => ({ byTabId: new Map() })) - -vi.mock('./terminal-parked-tab-watchers', () => ({ - canWatcherCoverParkedTerminalTab: (_worktreeId: string, tab: TerminalTab) => - coverage.byTabId.get(tab.id) ?? true -})) - -const WORKTREE = 'repo::/wt' - -function terminalTab(id: string): TerminalTab { - return { id, ptyId: `${WORKTREE}@@${id}`, title: id } as TerminalTab -} - -function pinnedRecord(pinnedUntilMs: number | null): ParkVerdictFlipRecord { - return { - parked: false, - windowStartMs: 0, - flips: 0, - notified: false, - burstStartMs: 0, - burstFlips: 0, - pinnedUntilMs - } -} - -beforeEach(() => { - coverage.byTabId.clear() -}) - -describe('withholdUnparkableTerminalTabs', () => { - it('parks a covered tab with no pin', () => { - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a')], - coldParkedTabIds: new Set(['tab-a']), - parkVerdictRecords: new Map(), - nowMs: 1_000 - }) - - expect(parkedTabIds).toEqual(new Set(['tab-a'])) - expect(parkVerdictPinUntilMsByTabId.size).toBe(0) - }) - - // Why both returned values matter: the caller needs the deadline to schedule - // the recheck that lets the tab park again once damping lapses. - it('withholds a pinned tab and reports its deadline', () => { - const records = new Map([['tab-a', pinnedRecord(9_000)]]) - - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a')], - coldParkedTabIds: new Set(['tab-a']), - parkVerdictRecords: records, - nowMs: 1_000 - }) - - expect(parkedTabIds.has('tab-a')).toBe(false) - expect(parkVerdictPinUntilMsByTabId.get('tab-a')).toBe(9_000) - }) - - it('withholds a tab the watchers cannot cover', () => { - coverage.byTabId.set('tab-a', false) - - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a')], - coldParkedTabIds: new Set(['tab-a']), - parkVerdictRecords: new Map(), - nowMs: 1_000 - }) - - expect(parkedTabIds.has('tab-a')).toBe(false) - // No pin: an uncovered tab has no damping deadline to recheck at. - expect(parkVerdictPinUntilMsByTabId.size).toBe(0) - }) - - // Why: an expired pin must hand the tab back to the parking policy, not - // strand it mounted for the rest of the session. - it('parks a tab whose pin has lapsed', () => { - const records = new Map([['tab-a', pinnedRecord(500)]]) - - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a')], - coldParkedTabIds: new Set(['tab-a']), - parkVerdictRecords: records, - nowMs: 1_000 - }) - - expect(parkedTabIds.has('tab-a')).toBe(true) - expect(parkVerdictPinUntilMsByTabId.size).toBe(0) - }) - - it('does not consult non-candidates and leaves the input set untouched', () => { - const coldParkedTabIds = new Set(['tab-a']) - coverage.byTabId.set('tab-b', false) - - const { parkedTabIds } = withholdUnparkableTerminalTabs({ - worktreeId: WORKTREE, - terminalTabs: [terminalTab('tab-a'), terminalTab('tab-b')], - coldParkedTabIds, - parkVerdictRecords: new Map(), - nowMs: 1_000 - }) - - expect(parkedTabIds).toEqual(new Set(['tab-a'])) - expect(coldParkedTabIds).toEqual(new Set(['tab-a'])) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts b/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts deleted file mode 100644 index 35be4446cb5..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-withheld-tabs.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Last gate between a cold-park candidate and an unmounted pane. - * - * Two reasons withhold a tab, both settling on the safe mounted side: the byte - * watchers cannot cover it, or a verdict-flip burst pinned it. The pin deadline - * is returned because it is also the only remaining recheck wakeup once damping - * has stopped the churn that was waking the parking effect. - */ -import type { TerminalTab } from '../../../../shared/types' -import { - getParkVerdictUnparkPinUntilMs, - type ParkVerdictFlipRecord -} from './terminal-park-verdict-flip-telemetry' -import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers' - -export type WithheldTerminalTabParking = { - parkedTabIds: Set - parkVerdictPinUntilMsByTabId: Map -} - -export function withholdUnparkableTerminalTabs(args: { - worktreeId: string - terminalTabs: readonly TerminalTab[] - coldParkedTabIds: ReadonlySet - parkVerdictRecords: Map - nowMs: number -}): WithheldTerminalTabParking { - const parkedTabIds = new Set(args.coldParkedTabIds) - const parkVerdictPinUntilMsByTabId = new Map() - - for (const terminalTab of args.terminalTabs) { - if (!parkedTabIds.has(terminalTab.id)) { - continue - } - const parkVerdictPinUntilMs = getParkVerdictUnparkPinUntilMs({ - records: args.parkVerdictRecords, - tabId: terminalTab.id, - nowMs: args.nowMs - }) - if (parkVerdictPinUntilMs !== null) { - parkVerdictPinUntilMsByTabId.set(terminalTab.id, parkVerdictPinUntilMs) - } - // Why coverage matters: a parked tab the watchers cannot reach goes silent - // for bells/titles/completions — the failure that sank the first attempt. - if ( - parkVerdictPinUntilMs !== null || - !canWatcherCoverParkedTerminalTab(args.worktreeId, terminalTab) - ) { - parkedTabIds.delete(terminalTab.id) - } - } - - return { parkedTabIds, parkVerdictPinUntilMsByTabId } -} diff --git a/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts index 3a11fab51fd..53c320f889a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts +++ b/src/renderer/src/components/terminal-pane/terminal-eviction-exempt-tabs.ts @@ -36,7 +36,7 @@ export function isEvictionExemptTerminalTab( if (isEvictionExemptTerminalPty(tab.ptyId, worktreeId)) { return true } - return resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()).some((pane) => + return resolveParkedTerminalPaneCandidates(worktreeId, tab, useAppStore.getState()).some((pane) => isEvictionExemptTerminalPty(pane.ptyId, worktreeId) ) } diff --git a/src/renderer/src/components/terminal-pane/terminal-park-episode-lease.test.ts b/src/renderer/src/components/terminal-pane/terminal-park-episode-lease.test.ts new file mode 100644 index 00000000000..5f63a3f5abb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-park-episode-lease.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { + acknowledgeTerminalParkEpisodeLease, + reconcileTerminalParkEpisodeLease, + terminalParkEpisodeLeaseUnmountsPane, + type TerminalParkEpisodeLease +} from './terminal-park-episode-lease' +import type { + TerminalParkedWatcherCoveragePlan, + TerminalParkedWatcherCoveredPlan +} from './terminal-parked-watcher-coverage-plan' + +const coveredPlan = (materialKey: string): TerminalParkedWatcherCoveredPlan => ({ + status: 'covered', + materialKey, + worktreeId: 'worktree-1', + tabId: 'tab-1', + tabPtyId: 'pty-1', + generation: 1, + panes: [{ leafId: '11111111-1111-4111-8111-111111111111', ptyId: 'pty-1' }] +}) + +describe('terminal park episode lease', () => { + it('requests covered plans and becomes covering only after an exact acknowledgement', () => { + const requested = reconcileTerminalParkEpisodeLease(null, coveredPlan('key-1')) + const covering = acknowledgeTerminalParkEpisodeLease(requested, { + status: 'covering', + tabId: 'tab-1', + materialKey: 'key-1', + watchedPtyIds: ['pty-1'] + }) + + expect(requested.phase).toBe('requested') + expect(covering.phase).toBe('covering') + expect(terminalParkEpisodeLeaseUnmountsPane(covering)).toBe(true) + }) + + it('keeps covering acknowledgements identity-stable across repeated effects', () => { + const covering: TerminalParkEpisodeLease = { + phase: 'covering', + plan: coveredPlan('key-1') + } + + expect( + acknowledgeTerminalParkEpisodeLease(covering, { + status: 'covering', + tabId: 'tab-1', + materialKey: 'key-1', + watchedPtyIds: ['pty-1'] + }) + ).toBe(covering) + }) + + it('latches rejection until the semantic key changes', () => { + const requested = reconcileTerminalParkEpisodeLease(null, coveredPlan('key-1')) + const rejected = acknowledgeTerminalParkEpisodeLease(requested, { + status: 'failed', + tabId: 'tab-1', + materialKey: 'key-1', + reason: 'watcher-coverage-incomplete', + expectedPtyIds: ['pty-1'], + watchedPtyIds: [] + }) + + expect(reconcileTerminalParkEpisodeLease(rejected, coveredPlan('key-1'))).toBe(rejected) + expect(reconcileTerminalParkEpisodeLease(rejected, coveredPlan('key-2')).phase).toBe( + 'requested' + ) + expect(terminalParkEpisodeLeaseUnmountsPane(rejected)).toBe(false) + }) + + it('keeps pending and blocked plans mounted without a timer', () => { + const pending: TerminalParkedWatcherCoveragePlan = { + ...coveredPlan('pending-key'), + status: 'pending', + issue: { reason: 'provider-capability-pending' } + } + const blocked: TerminalParkedWatcherCoveragePlan = { + ...coveredPlan('blocked-key'), + status: 'blocked', + issue: { reason: 'provider-snapshot-unavailable' } + } + + expect( + terminalParkEpisodeLeaseUnmountsPane(reconcileTerminalParkEpisodeLease(null, pending)) + ).toBe(false) + expect( + terminalParkEpisodeLeaseUnmountsPane(reconcileTerminalParkEpisodeLease(null, blocked)) + ).toBe(false) + }) + + it('makes retention force-parking an explicit uncovered bypass', () => { + const blocked: TerminalParkedWatcherCoveragePlan = { + ...coveredPlan('blocked-key'), + status: 'blocked', + issue: { reason: 'provider-snapshot-unavailable' } + } + const forced = reconcileTerminalParkEpisodeLease(null, blocked, { forceUnmount: true }) + + expect(forced.phase).toBe('forced') + expect(terminalParkEpisodeLeaseUnmountsPane(forced)).toBe(true) + expect(reconcileTerminalParkEpisodeLease(forced, blocked, { forceUnmount: true })).toBe(forced) + expect(reconcileTerminalParkEpisodeLease(forced, blocked).phase).toBe('blocked') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-park-episode-lease.ts b/src/renderer/src/components/terminal-pane/terminal-park-episode-lease.ts new file mode 100644 index 00000000000..54899777c6a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-park-episode-lease.ts @@ -0,0 +1,112 @@ +import type { + TerminalParkedWatcherBlockedPlan, + TerminalParkedWatcherCoveragePlan, + TerminalParkedWatcherCoveredPlan, + TerminalParkedWatcherPendingPlan +} from './terminal-parked-watcher-coverage-plan' +import type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' + +export type ParkedTerminalTabWatcherSyncArgs = { + worktreeId: string + tabs: readonly ParkableTerminalTabModel[] + parkedTabIds: ReadonlySet + restoreTitleOnStartTabIds?: ReadonlySet +} + +export function terminalParkedWatcherPlanPtyIds( + plan: TerminalParkedWatcherCoveragePlan | undefined +): string[] { + return (plan?.panes ?? []) + .flatMap((pane) => (pane.ptyId === null ? [] : [pane.ptyId])) + .sort((left, right) => left.localeCompare(right)) +} + +export type TerminalParkedWatcherSyncFailureReason = + | 'coverage-plan-missing' + | 'coverage-plan-not-covered' + | 'material-changed' + | 'watcher-start-failed' + | 'watcher-coverage-incomplete' + | 'watcher-ownership-lost' + +export type TerminalParkedWatcherSyncAcknowledgement = + | { + status: 'covering' + tabId: string + materialKey: string + watchedPtyIds: readonly string[] + } + | { + status: 'failed' + tabId: string + materialKey: string | null + reason: TerminalParkedWatcherSyncFailureReason + expectedPtyIds: readonly string[] + watchedPtyIds: readonly string[] + } + | { + status: 'forced' + tabId: string + materialKey: string | null + watchedPtyIds: readonly string[] + } + +export type TerminalParkEpisodeLease = + | { phase: 'pending'; plan: TerminalParkedWatcherPendingPlan } + | { phase: 'blocked'; plan: TerminalParkedWatcherBlockedPlan } + | { phase: 'requested'; plan: TerminalParkedWatcherCoveredPlan } + | { phase: 'covering'; plan: TerminalParkedWatcherCoveredPlan } + | { phase: 'forced'; plan: TerminalParkedWatcherCoveragePlan } + | { + phase: 'rejected' + plan: TerminalParkedWatcherCoveredPlan + reason: TerminalParkedWatcherSyncFailureReason + } + +export function reconcileTerminalParkEpisodeLease( + lease: TerminalParkEpisodeLease | null, + plan: TerminalParkedWatcherCoveragePlan, + options?: { forceUnmount?: boolean } +): TerminalParkEpisodeLease { + if (options?.forceUnmount) { + return lease?.phase === 'forced' && lease.plan.materialKey === plan.materialKey + ? lease + : { phase: 'forced', plan } + } + if (lease?.phase !== 'forced' && lease?.plan.materialKey === plan.materialKey) { + return lease + } + if (plan.status === 'pending') { + return { phase: 'pending', plan } + } + if (plan.status === 'blocked') { + return { phase: 'blocked', plan } + } + return { phase: 'requested', plan } +} + +export function acknowledgeTerminalParkEpisodeLease( + lease: TerminalParkEpisodeLease, + acknowledgement: TerminalParkedWatcherSyncAcknowledgement +): TerminalParkEpisodeLease { + if (lease.phase === 'forced' || acknowledgement.status === 'forced') { + return lease + } + if ( + acknowledgement.tabId !== lease.plan.tabId || + acknowledgement.materialKey !== lease.plan.materialKey || + (lease.phase !== 'requested' && lease.phase !== 'covering') + ) { + return lease + } + if (acknowledgement.status === 'covering') { + return lease.phase === 'covering' ? lease : { phase: 'covering', plan: lease.plan } + } + return { phase: 'rejected', plan: lease.plan, reason: acknowledgement.reason } +} + +export function terminalParkEpisodeLeaseUnmountsPane( + lease: TerminalParkEpisodeLease | null +): boolean { + return lease?.phase === 'requested' || lease?.phase === 'covering' || lease?.phase === 'forced' +} diff --git a/src/renderer/src/components/terminal-pane/terminal-park-episode-set.test.ts b/src/renderer/src/components/terminal-pane/terminal-park-episode-set.test.ts new file mode 100644 index 00000000000..ee24929af68 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-park-episode-set.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest' +import { + acknowledgeTerminalParkEpisodeSet, + reconcileTerminalParkEpisodeSet, + selectAcknowledgedTerminalParkEpisodeUnmounts, + selectCurrentTerminalParkEpisodeUnmounts, + selectTerminalParkEpisodeWatcherTabIds, + type TerminalParkEpisodeSet, + type TerminalParkEpisodeTab +} from './terminal-park-episode-set' +import type { + TerminalParkedWatcherBlockedPlan, + TerminalParkedWatcherCoveredPlan, + TerminalParkedWatcherPendingPlan +} from './terminal-parked-watcher-coverage-plan' + +const WORKTREE_ID = 'worktree-1' +const TAB_ID = 'tab-1' +const OTHER_TAB_ID = 'tab-2' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function tab(id = TAB_ID): TerminalParkEpisodeTab { + return { id, ptyId: `pty-${id}`, generation: 1 } +} + +function coveredPlan(materialKey: string, tabId = TAB_ID): TerminalParkedWatcherCoveredPlan { + return { + status: 'covered', + materialKey, + worktreeId: WORKTREE_ID, + tabId, + tabPtyId: `pty-${tabId}`, + generation: 1, + panes: [{ leafId: LEAF_ID, ptyId: `pty-${tabId}` }] + } +} + +function pendingPlan(materialKey: string): TerminalParkedWatcherPendingPlan { + return { + ...coveredPlan(materialKey), + status: 'pending', + issue: { reason: 'provider-capability-pending' } + } +} + +function blockedPlan(materialKey: string): TerminalParkedWatcherBlockedPlan { + return { + ...coveredPlan(materialKey), + status: 'blocked', + issue: { reason: 'provider-snapshot-unavailable' } + } +} + +function rejectRequestedLease( + current: TerminalParkEpisodeSet, + plan: TerminalParkedWatcherCoveredPlan +): TerminalParkEpisodeSet { + const requested = reconcileTerminalParkEpisodeSet({ + current, + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + plan: () => plan + }) + return acknowledgeTerminalParkEpisodeSet(requested.leases, [ + { + status: 'failed', + tabId: TAB_ID, + materialKey: plan.materialKey, + reason: 'watcher-coverage-incomplete', + expectedPtyIds: [`pty-${TAB_ID}`], + watchedPtyIds: [] + } + ]).leases +} + +describe('terminal park episode set', () => { + it('unmounts a covered request', () => { + const plan = coveredPlan('key-1') + const result = reconcileTerminalParkEpisodeSet({ + current: new Map(), + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + plan: () => plan + }) + + expect(result.leases.get(TAB_ID)).toMatchObject({ phase: 'requested', plan }) + expect(result.plansByTabId.get(TAB_ID)).toBe(plan) + expect(result.unmountTabIds).toEqual(new Set([TAB_ID])) + }) + + it('keeps a same-key rejection mounted across reconciliation', () => { + const plan = coveredPlan('key-1') + const rejected = rejectRequestedLease(new Map(), plan) + const result = reconcileTerminalParkEpisodeSet({ + current: rejected, + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + plan: () => plan + }) + + expect(result.leases).toBe(rejected) + expect(result.leases.get(TAB_ID)?.phase).toBe('rejected') + expect(result.unmountTabIds).toEqual(new Set()) + }) + + it('rearms a rejected episode when its material key changes', () => { + const rejected = rejectRequestedLease(new Map(), coveredPlan('key-1')) + const result = reconcileTerminalParkEpisodeSet({ + current: rejected, + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + plan: () => coveredPlan('key-2') + }) + + expect(result.leases.get(TAB_ID)).toMatchObject({ + phase: 'requested', + plan: { materialKey: 'key-2' } + }) + expect(result.unmountTabIds).toEqual(new Set([TAB_ID])) + }) + + it('refuses a stale lease key before reconciliation', () => { + const requested = reconcileTerminalParkEpisodeSet({ + current: new Map(), + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + plan: () => coveredPlan('key-1') + }) + + expect( + selectCurrentTerminalParkEpisodeUnmounts({ + leases: requested.leases, + plansByTabId: new Map([[TAB_ID, coveredPlan('key-2')]]), + requestedTabIds: new Set([TAB_ID]) + }) + ).toEqual(new Set()) + }) + + it('does not commit an acknowledgement-required request before watcher coverage', () => { + const plan = coveredPlan('key-1') + const requested = reconcileTerminalParkEpisodeSet({ + current: new Map(), + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + plan: () => plan + }) + const selectUnmounts = (leases: TerminalParkEpisodeSet): Set => + selectAcknowledgedTerminalParkEpisodeUnmounts({ + leases, + plansByTabId: requested.plansByTabId, + requestedTabIds: new Set([TAB_ID]), + acknowledgementRequiredTabIds: new Set([TAB_ID]) + }) + + expect(selectUnmounts(requested.leases)).toEqual(new Set()) + + const covering = acknowledgeTerminalParkEpisodeSet(requested.leases, [ + { + status: 'covering', + tabId: TAB_ID, + materialKey: plan.materialKey, + watchedPtyIds: [`pty-${TAB_ID}`] + } + ]) + expect(selectUnmounts(covering.leases)).toEqual(new Set([TAB_ID])) + }) + + it('keeps a newly covering deferred watcher across stale rendered state', () => { + const plan = coveredPlan('key-1') + const requested = reconcileTerminalParkEpisodeSet({ + current: new Map(), + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + plan: () => plan + }) + const covering = acknowledgeTerminalParkEpisodeSet(requested.leases, [ + { + status: 'covering', + tabId: TAB_ID, + materialKey: plan.materialKey, + watchedPtyIds: [`pty-${TAB_ID}`] + } + ]) + + expect( + selectTerminalParkEpisodeWatcherTabIds({ + worktreeId: WORKTREE_ID, + tabIds: [TAB_ID], + leases: covering.leases, + plansByTabId: requested.plansByTabId, + renderedUnmountTabIds: new Set(), + activationDeferredTabIds: new Set([TAB_ID]) + }) + ).toEqual(new Set([TAB_ID])) + }) + + it('does not start a newly requested ordinary watcher before its unmount render', () => { + const requested = reconcileTerminalParkEpisodeSet({ + current: new Map(), + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + plan: () => coveredPlan('key-1') + }) + + expect( + selectTerminalParkEpisodeWatcherTabIds({ + worktreeId: WORKTREE_ID, + tabIds: [TAB_ID], + leases: requested.leases, + plansByTabId: requested.plansByTabId, + renderedUnmountTabIds: new Set() + }) + ).toEqual(new Set()) + }) + + it('drops a revealed tab from stale rendered watcher state', () => { + expect( + selectTerminalParkEpisodeWatcherTabIds({ + worktreeId: WORKTREE_ID, + tabIds: [TAB_ID], + leases: new Map(), + plansByTabId: new Map(), + renderedUnmountTabIds: new Set([TAB_ID]) + }) + ).toEqual(new Set()) + }) + + it.each([ + ['pending', pendingPlan('pending-key')], + ['blocked', blockedPlan('blocked-key')] + ] as const)('force-unmounts a %s request', (_status, plan) => { + const result = reconcileTerminalParkEpisodeSet({ + current: new Map(), + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + forcedTabIds: new Set([TAB_ID]), + plan: () => plan + }) + + expect(result.leases.get(TAB_ID)?.phase).toBe('forced') + expect(result.unmountTabIds).toEqual(new Set([TAB_ID])) + }) + + it('keeps covering acknowledgements render-stable and reports failures', () => { + const plan = coveredPlan('key-1') + const requested = reconcileTerminalParkEpisodeSet({ + current: new Map(), + tabs: [tab()], + requestedTabIds: new Set([TAB_ID]), + plan: () => plan + }) + const covering = acknowledgeTerminalParkEpisodeSet(requested.leases, [ + { + status: 'covering', + tabId: TAB_ID, + materialKey: plan.materialKey, + watchedPtyIds: [`pty-${TAB_ID}`] + } + ]) + const failed = acknowledgeTerminalParkEpisodeSet(requested.leases, [ + { + status: 'failed', + tabId: TAB_ID, + materialKey: plan.materialKey, + reason: 'watcher-start-failed', + expectedPtyIds: [`pty-${TAB_ID}`], + watchedPtyIds: [] + } + ]) + + expect(covering.renderChanged).toBe(false) + expect(covering.leases.get(TAB_ID)?.phase).toBe('covering') + expect(failed.renderChanged).toBe(true) + expect(failed.leases.get(TAB_ID)?.phase).toBe('rejected') + }) + + it('drops leases for removed tabs', () => { + const current = reconcileTerminalParkEpisodeSet({ + current: new Map(), + tabs: [tab(), tab(OTHER_TAB_ID)], + requestedTabIds: new Set([TAB_ID, OTHER_TAB_ID]), + plan: (entry) => coveredPlan(`key-${entry.id}`, entry.id) + }) + const result = reconcileTerminalParkEpisodeSet({ + current: current.leases, + tabs: [tab()], + requestedTabIds: new Set([TAB_ID, OTHER_TAB_ID]), + plan: () => coveredPlan(`key-${TAB_ID}`) + }) + + expect(result.leases).not.toBe(current.leases) + expect(Array.from(result.leases.keys())).toEqual([TAB_ID]) + expect(Array.from(result.plansByTabId.keys())).toEqual([TAB_ID]) + expect(result.unmountTabIds).toEqual(new Set([TAB_ID])) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-park-episode-set.ts b/src/renderer/src/components/terminal-pane/terminal-park-episode-set.ts new file mode 100644 index 00000000000..4f767c73468 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-park-episode-set.ts @@ -0,0 +1,150 @@ +import { + acknowledgeTerminalParkEpisodeLease, + reconcileTerminalParkEpisodeLease, + terminalParkEpisodeLeaseUnmountsPane, + type TerminalParkEpisodeLease, + type TerminalParkedWatcherSyncAcknowledgement +} from './terminal-park-episode-lease' +import type { TerminalParkedWatcherCoveragePlan } from './terminal-parked-watcher-coverage-plan' +import type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' + +export type TerminalParkEpisodeTab = ParkableTerminalTabModel + +export type TerminalParkEpisodeSet = ReadonlyMap + +export function reconcileTerminalParkEpisodeSet(args: { + current: TerminalParkEpisodeSet + tabs: readonly TerminalParkEpisodeTab[] + requestedTabIds: ReadonlySet + forcedTabIds?: ReadonlySet + plan: (tab: TerminalParkEpisodeTab) => TerminalParkedWatcherCoveragePlan +}): { + leases: TerminalParkEpisodeSet + plansByTabId: ReadonlyMap + unmountTabIds: ReadonlySet +} { + const leases = new Map() + const plansByTabId = new Map() + const unmountTabIds = new Set() + + for (const tab of args.tabs) { + if (!args.requestedTabIds.has(tab.id)) { + continue + } + const plan = args.plan(tab) + const lease = reconcileTerminalParkEpisodeLease(args.current.get(tab.id) ?? null, plan, { + forceUnmount: args.forcedTabIds?.has(tab.id) === true + }) + leases.set(tab.id, lease) + plansByTabId.set(tab.id, plan) + if (terminalParkEpisodeLeaseUnmountsPane(lease)) { + unmountTabIds.add(tab.id) + } + } + + const unchanged = + leases.size === args.current.size && + Array.from(leases).every(([tabId, lease]) => args.current.get(tabId) === lease) + return { + leases: unchanged ? args.current : leases, + plansByTabId, + unmountTabIds + } +} + +export function selectCurrentTerminalParkEpisodeUnmounts(args: { + leases: TerminalParkEpisodeSet + plansByTabId: ReadonlyMap + requestedTabIds: ReadonlySet + forcedTabIds?: ReadonlySet +}): Set { + const unmountTabIds = new Set() + for (const tabId of args.requestedTabIds) { + if (args.forcedTabIds?.has(tabId)) { + unmountTabIds.add(tabId) + continue + } + const lease = args.leases.get(tabId) + const plan = args.plansByTabId.get(tabId) + if ( + lease && + plan && + lease.plan.materialKey === plan.materialKey && + terminalParkEpisodeLeaseUnmountsPane(lease) + ) { + unmountTabIds.add(tabId) + } + } + return unmountTabIds +} + +export function selectAcknowledgedTerminalParkEpisodeUnmounts(args: { + leases: TerminalParkEpisodeSet + plansByTabId: ReadonlyMap + requestedTabIds: ReadonlySet + forcedTabIds?: ReadonlySet + acknowledgementRequiredTabIds: ReadonlySet +}): Set { + const unmountTabIds = selectCurrentTerminalParkEpisodeUnmounts(args) + for (const tabId of args.acknowledgementRequiredTabIds) { + if (args.leases.get(tabId)?.phase === 'requested') { + unmountTabIds.delete(tabId) + } + } + return unmountTabIds +} + +export function selectTerminalParkEpisodeWatcherTabIds(args: { + worktreeId: string + tabIds: readonly string[] + leases: TerminalParkEpisodeSet + plansByTabId: ReadonlyMap + renderedUnmountTabIds: ReadonlySet + activationDeferredTabIds?: ReadonlySet +}): Set { + const watcherTabIds = new Set() + for (const tabId of args.tabIds) { + const lease = args.leases.get(tabId) + const plan = args.plansByTabId.get(tabId) + if ( + !lease || + !plan || + plan.worktreeId !== args.worktreeId || + lease.plan.materialKey !== plan.materialKey || + !terminalParkEpisodeLeaseUnmountsPane(lease) + ) { + continue + } + const establishedDeferredWatcher = + args.activationDeferredTabIds?.has(tabId) === true && + (lease.phase === 'covering' || lease.phase === 'forced') + if (args.renderedUnmountTabIds.has(tabId) || establishedDeferredWatcher) { + watcherTabIds.add(tabId) + } + } + return watcherTabIds +} + +export function acknowledgeTerminalParkEpisodeSet( + current: TerminalParkEpisodeSet, + acknowledgements: readonly TerminalParkedWatcherSyncAcknowledgement[] +): { leases: TerminalParkEpisodeSet; renderChanged: boolean } { + let leases: Map | null = null + let renderChanged = false + for (const acknowledgement of acknowledgements) { + const currentLease = (leases ?? current).get(acknowledgement.tabId) + if (!currentLease) { + continue + } + const nextLease = acknowledgeTerminalParkEpisodeLease(currentLease, acknowledgement) + if (nextLease === currentLease) { + continue + } + leases ??= new Map(current) + leases.set(acknowledgement.tabId, nextLease) + renderChanged ||= + terminalParkEpisodeLeaseUnmountsPane(currentLease) !== + terminalParkEpisodeLeaseUnmountsPane(nextLease) + } + return { leases: leases ?? current, renderChanged } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts index 49cebe2aea4..01c755096e0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts @@ -1,11 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { - TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS, - TERMINAL_TAB_PARK_FLIP_COMMIT_COST, TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, TERMINAL_TAB_PARK_FLIP_WINDOW_MS, - getParkVerdictUnparkPinUntilMs, recordParkVerdictFlips, type ParkVerdictFlipRecord } from './terminal-park-verdict-flip-telemetry' @@ -16,8 +12,6 @@ vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ })) const TAB = 'tab-1' -/** Slower than the burst window, so only the notice limit can fire. */ -const SLOW_CHURN_STEP_MS = TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS * 4 function observe(args: { records: Map @@ -33,180 +27,116 @@ function observe(args: { }) } -beforeEach(() => { - recordBreadcrumb.mockClear() -}) - -// Why asserted: the whole point of the burst trigger is that it is derived from -// React's 50-commit bail, not copied from the breadcrumb notice limit. If the -// two ever converge again the damping stops firing before React throws #185. -describe('burst damping threshold', () => { - it('stays under React NESTED_UPDATE_LIMIT at the assumed commits-per-flip cost', () => { - expect(TERMINAL_TAB_PARK_FLIP_BURST_LIMIT * TERMINAL_TAB_PARK_FLIP_COMMIT_COST).toBeLessThan(50) - expect(TERMINAL_TAB_PARK_FLIP_BURST_LIMIT).toBeLessThan(TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT) - }) -}) +beforeEach(() => recordBreadcrumb.mockClear()) describe('recordParkVerdictFlips', () => { it('stays silent for a stable verdict', () => { const records = new Map() - for (let i = 0; i < 100; i += 1) { - observe({ records, parked: true, nowMs: 1_000 + i * 1_000 }) + for (let index = 0; index < 100; index += 1) { + observe({ records, parked: true, nowMs: 1_000 + index * 1_000 }) } expect(recordBreadcrumb).not.toHaveBeenCalled() expect(records.get(TAB)?.flips).toBe(0) }) - it('emits one burst breadcrumb once the verdict churns at render cadence', () => { + it('emits one breadcrumb per window once the verdict churns', () => { const records = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) + for (let index = 0; index < 40; index += 1) { + observe({ records, parked: index % 2 === 0, nowMs: 1_000 + index * 10 }) } expect(recordBreadcrumb).toHaveBeenCalledTimes(1) expect(recordBreadcrumb).toHaveBeenCalledWith( 'terminal_park_verdict_churn', - expect.objectContaining({ - tabId: TAB, - trigger: 'burst', - flips: TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - pinnedForMs: TERMINAL_TAB_PARK_FLIP_WINDOW_MS - }) + expect.objectContaining({ tabId: TAB, flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT }) ) }) - // Why: the two triggers answer different questions — 'burst' means damping - // engaged before React could bail, 'window' means churn too slow to loop. - it('separates a damped burst from slow churn', () => { + it('reports elapsed time for tight and slow churn', () => { const tightRecords = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records: tightRecords, parked: i % 2 === 0, nowMs: 1_000 + i }) + for (let index = 0; index < 40; index += 1) { + observe({ records: tightRecords, parked: index % 2 === 0, nowMs: 1_000 + index }) } expect(recordBreadcrumb).toHaveBeenCalledWith( 'terminal_park_verdict_churn', - expect.objectContaining({ - trigger: 'burst', - flips: TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - elapsedMs: TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - windowMs: TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS - }) + expect.objectContaining({ flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, elapsedMs: 12 }) ) recordBreadcrumb.mockClear() - const slowRecords = new Map() - for (let i = 0; i < TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT + 1; i += 1) { - observe({ records: slowRecords, parked: i % 2 === 0, nowMs: 1_000 + i * SLOW_CHURN_STEP_MS }) + for (let index = 0; index < 13; index += 1) { + observe({ records: slowRecords, parked: index % 2 === 0, nowMs: 1_000 + index * 4_000 }) } - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) expect(recordBreadcrumb).toHaveBeenCalledWith( 'terminal_park_verdict_churn', - expect.objectContaining({ - trigger: 'window', - flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, - elapsedMs: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT * SLOW_CHURN_STEP_MS, - windowMs: TERMINAL_TAB_PARK_FLIP_WINDOW_MS - }) + expect.objectContaining({ flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, elapsedMs: 48_000 }) ) }) - // Why: slow churn must not be damped — parking it out for a minute would cost - // a mounted pane's memory for a verdict that was never near React's bail. - it('does not pin churn spread past the burst window', () => { - const records = new Map() - for (let i = 0; i < TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT + 1; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * SLOW_CHURN_STEP_MS }) - } - - expect(records.get(TAB)?.pinnedUntilMs ?? null).toBeNull() - }) - it('re-arms after the window elapses', () => { const records = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) + for (let index = 0; index < 40; index += 1) { + observe({ records, parked: index % 2 === 0, nowMs: 1_000 + index * 10 }) } - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - const laterMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2 - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: laterMs + i * 10 }) + for (let index = 0; index < 40; index += 1) { + observe({ records, parked: index % 2 === 0, nowMs: laterMs + index * 10 }) } expect(recordBreadcrumb).toHaveBeenCalledTimes(2) }) - // Why: an unclamped backwards jump would freeze the window and suppress the - // very signal this module exists to capture. - it('treats a backwards clock jump as a fresh window', () => { - const records = new Map() - observe({ records, parked: true, nowMs: 10_000_000 }) - observe({ records, parked: false, nowMs: 1_000 }) + it('treats backwards and exactly elapsed clocks as fresh windows', () => { + const backward = new Map() + observe({ records: backward, parked: true, nowMs: 10_000_000 }) + observe({ records: backward, parked: false, nowMs: 1_000 }) + expect(backward.get(TAB)).toMatchObject({ windowStartMs: 1_000, flips: 1 }) - expect(records.get(TAB)?.windowStartMs).toBe(1_000) - expect(records.get(TAB)?.flips).toBe(1) + const elapsed = new Map() + observe({ records: elapsed, parked: true, nowMs: 1_000 }) + observe({ + records: elapsed, + parked: false, + nowMs: 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS + }) + expect(elapsed.get(TAB)).toMatchObject({ + windowStartMs: 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS, + flips: 1 + }) }) - // Why: >= is the boundary operator; a > regression would silently stretch the - // window and delay every notice by one full period. - it('treats an exactly-elapsed window as expired', () => { + it('honors window and notice overrides', () => { const records = new Map() - observe({ records, parked: true, nowMs: 1_000 }) - observe({ records, parked: false, nowMs: 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS }) - - expect(records.get(TAB)?.windowStartMs).toBe(1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS) - expect(records.get(TAB)?.flips).toBe(1) - }) - - it('honours the window, notice, burst-window and burst-limit overrides', () => { - const records = new Map() - for (let i = 0; i < 10; i += 1) { + for (let index = 0; index < 10; index += 1) { recordParkVerdictFlips({ records, liveTabIds: new Set([TAB]), - nextParkedTabIds: i % 2 === 0 ? new Set([TAB]) : new Set(), - nowMs: 1_000 + i * 100, - flipWindowMs: 5_000, - noticeLimit: 3, - burstWindowMs: 10, - burstLimit: 2 + nextParkedTabIds: index % 2 === 0 ? new Set([TAB]) : new Set(), + nowMs: 1_000 + index, + flipWindowMs: 500, + noticeLimit: 3 }) } - // Why 'window': the 100ms step outruns the 10ms burst window, so the burst - // counter resets on every flip and only the notice limit can fire. - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) expect(recordBreadcrumb).toHaveBeenCalledWith( 'terminal_park_verdict_churn', - expect.objectContaining({ trigger: 'window', flips: 3, windowMs: 5_000 }) + expect.objectContaining({ flips: 3, windowMs: 500 }) ) }) - it('keeps per-tab windows and notices independent', () => { + it('keeps tabs independent and drops closed tabs', () => { const records = new Map() const other = 'tab-2' - for (let i = 0; i < 40; i += 1) { + for (let index = 0; index < 40; index += 1) { recordParkVerdictFlips({ records, liveTabIds: new Set([TAB, other]), - // Why: TAB churns every call, other stays parked throughout. - nextParkedTabIds: i % 2 === 0 ? new Set([TAB, other]) : new Set([other]), - nowMs: 1_000 + i * 10 + nextParkedTabIds: index % 2 === 0 ? new Set([TAB, other]) : new Set([other]), + nowMs: 1_000 + index * 10 }) } - - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - expect(recordBreadcrumb).toHaveBeenCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ tabId: TAB }) - ) expect(records.get(other)?.flips).toBe(0) - }) - - it('drops records for tabs that no longer exist', () => { - const records = new Map() - observe({ records, parked: true, nowMs: 1_000 }) recordParkVerdictFlips({ records, @@ -214,126 +144,6 @@ describe('recordParkVerdictFlips', () => { nextParkedTabIds: new Set(), nowMs: 2_000 }) - expect(records.size).toBe(0) }) }) - -describe('getParkVerdictUnparkPinUntilMs', () => { - function churnToBurst(records: Map, startMs = 1_000): number { - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: startMs + i * 10 }) - } - // The first observation only seeds the record, so flip N lands one step later. - return startMs + TERMINAL_TAB_PARK_FLIP_BURST_LIMIT * 10 - } - - it('does not pin a stable verdict', () => { - const records = new Map() - observe({ records, parked: true, nowMs: 1_000 }) - - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: 2_000 })).toBeNull() - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: 'missing', nowMs: 2_000 })).toBeNull() - }) - - // Why the deadline and not a boolean: the caller has to schedule a recheck at - // it, or the pin never lifts once it has stopped the churn that woke the - // verdict effect. - it('reports the pin deadline one window out, then re-arms', () => { - const records = new Map() - const pinnedAtMs = churnToBurst(records) - const pinUntilMs = pinnedAtMs + TERMINAL_TAB_PARK_FLIP_WINDOW_MS - - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: pinnedAtMs + 1 })).toBe( - pinUntilMs - ) - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: pinUntilMs })).toBeNull() - expect(records.get(TAB)?.flips).toBe(0) - expect(records.get(TAB)?.burstFlips).toBe(0) - - recordBreadcrumb.mockClear() - churnToBurst(records, pinUntilMs) - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: pinUntilMs + 1 })).not.toBe( - null - ) - }) - - // Why: a backwards clock jump must release the pin, not strand it for a window. - it('releases the pin when the clock jumps backwards', () => { - const records = new Map() - churnToBurst(records) - - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: 5 })).toBeNull() - }) - - // Why: the notice window starts at the first flip and the pin starts one - // burst later, so the notice window always lapses first. Resetting it must - // not hand the pane back to the parking policy mid-damping. - it('survives a notice-window expiry that lands mid-pin', () => { - const records = new Map() - const pinnedAtMs = churnToBurst(records) - const pinUntilMs = pinnedAtMs + TERMINAL_TAB_PARK_FLIP_WINDOW_MS - const windowLapseMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS - - // An exogenous flip (visibility change, tab removal) after the notice - // window lapsed but before the pin deadline. - expect(windowLapseMs).toBeLessThan(pinUntilMs) - observe({ records, parked: true, nowMs: windowLapseMs }) - - expect(records.get(TAB)?.flips).toBe(1) - expect(getParkVerdictUnparkPinUntilMs({ records, tabId: TAB, nowMs: windowLapseMs })).toBe( - pinUntilMs - ) - }) -}) - -// Why liveness and not presence: a pinned tab can stop being cold-park eligible -// before its deadline, and nothing consults getParkVerdictUnparkPinUntilMs for -// it again. A stale deadline must not silence churn telemetry forever. -describe('expired pins stop gating breadcrumbs', () => { - it('re-arms damping and notices without a getParkVerdictUnparkPinUntilMs call', () => { - const records = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) - } - expect(recordBreadcrumb).toHaveBeenCalledTimes(1) - expect(recordBreadcrumb).toHaveBeenLastCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ trigger: 'burst' }) - ) - - // Churn resumes past the pin deadline; the pin was never read back. - const afterPinMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2 - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: afterPinMs + i * 10 }) - } - - expect(recordBreadcrumb).toHaveBeenCalledTimes(2) - expect(recordBreadcrumb).toHaveBeenLastCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ trigger: 'burst' }) - ) - expect(records.get(TAB)?.pinnedUntilMs).toBeGreaterThan(afterPinMs) - }) - - it('still reports slow churn after a pin lapses', () => { - const records = new Map() - for (let i = 0; i < 40; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) - } - recordBreadcrumb.mockClear() - - // Slow churn only: each step outruns the burst window, so the notice limit - // is the only trigger left. It must not stay gated by the lapsed pin. - const afterPinMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2 - for (let i = 0; i <= TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT; i += 1) { - observe({ records, parked: i % 2 === 0, nowMs: afterPinMs + i * SLOW_CHURN_STEP_MS }) - } - - expect(recordBreadcrumb).toHaveBeenCalledWith( - 'terminal_park_verdict_churn', - expect.objectContaining({ trigger: 'window' }) - ) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts index 17b53d53785..1f5e96d2b05 100644 --- a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts @@ -1,83 +1,17 @@ -/** - * Cold-park verdict telemetry and a safe-side circuit breaker. - * Field breadcrumbs prove render-cadence flips, but not which eligibility input - * oscillates; burst damping keeps the pane mounted before React reaches #185. - * - * Scope: flips are counted on the rendered verdict, but the pin can only - * subtract from the cold-park candidate set. Churn driven by the other parked - * inputs (forced parking, portal ownership, deferred activation mounts) is - * observed and breadcrumbed, not damped — read a repeating `burst` crumb for - * one tab as "damping did not reach the oscillating input". - */ +/** Observes rendered cold-park verdict churn without changing parking policy. */ import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' export const TERMINAL_TAB_PARK_FLIP_WINDOW_MS = 60_000 -/** Flips per window that no sane park policy should reach. Breadcrumb only. */ export const TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT = 12 -/** react-dom 19.2.x nested commit limit. */ -const REACT_NESTED_UPDATE_LIMIT = 50 -/** Measured upper bound after the passive-effect pin engages. */ -const PARK_PIN_SETTLE_COMMITS = 6 -/** Worst-case commits from pane, watcher, and store work per verdict flip. */ -export const TERMINAL_TAB_PARK_FLIP_COMMIT_COST = 12 -/** Pin threshold derived from React's remaining commit budget. */ -export const TERMINAL_TAB_PARK_FLIP_BURST_LIMIT = Math.max( - 2, - Math.floor( - (REACT_NESTED_UPDATE_LIMIT - PARK_PIN_SETTLE_COMMITS) / TERMINAL_TAB_PARK_FLIP_COMMIT_COST - ) -) -/** Honest cold parking cannot round-trip inside this horizon. */ -export const TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS = 1_000 - export type ParkVerdictFlipRecord = { parked: boolean windowStartMs: number flips: number notified: boolean - burstStartMs: number - burstFlips: number - /** Set when a flip burst engaged damping; the verdict stays unparked until then. */ - pinnedUntilMs?: number | null } -// Why it leaves pinnedUntilMs alone: the notice window is 60s from the first -// flip, so it lapses mid-pin; clearing here would release damping early. -function resetFlipWindows(record: ParkVerdictFlipRecord, nowMs: number): void { - record.windowStartMs = nowMs - record.flips = 0 - record.notified = false - record.burstStartMs = nowMs - record.burstFlips = 0 -} - -// Why liveness and not presence: a tab pinned while cold-park-eligible can stop -// being a candidate before its deadline, and nothing would consult it again. An -// expired pin must stop damping and stop gating breadcrumbs on its own. -function isParkVerdictPinLive(record: ParkVerdictFlipRecord, nowMs: number): boolean { - return record.pinnedUntilMs != null && nowMs < record.pinnedUntilMs -} - -/** Returns the safe-side pin deadline and re-arms an expired window. */ -export function getParkVerdictUnparkPinUntilMs(args: { - records: Map - tabId: string - nowMs: number -}): number | null { - const record = args.records.get(args.tabId) - if (record?.pinnedUntilMs == null) { - return null - } - if (!isParkVerdictPinLive(record, args.nowMs) || args.nowMs < record.windowStartMs) { - resetFlipWindows(record, args.nowMs) - record.pinnedUntilMs = null - return null - } - return record.pinnedUntilMs -} - -/** Records park-verdict churn per tab; damps bursts and breadcrumbs the rest. */ +/** Records park-verdict churn per tab; emits one breadcrumb per window. */ export function recordParkVerdictFlips(args: { records: Map liveTabIds: ReadonlySet @@ -85,8 +19,6 @@ export function recordParkVerdictFlips(args: { nowMs: number flipWindowMs?: number noticeLimit?: number - burstWindowMs?: number - burstLimit?: number }): void { const { records, @@ -94,9 +26,7 @@ export function recordParkVerdictFlips(args: { nextParkedTabIds, nowMs, flipWindowMs = TERMINAL_TAB_PARK_FLIP_WINDOW_MS, - noticeLimit = TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, - burstWindowMs = TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS, - burstLimit = TERMINAL_TAB_PARK_FLIP_BURST_LIMIT + noticeLimit = TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT } = args for (const tabId of Array.from(records.keys())) { @@ -110,59 +40,27 @@ export function recordParkVerdictFlips(args: { const record = records.get(tabId) if (!record) { - records.set(tabId, { - parked, - windowStartMs: nowMs, - flips: 0, - notified: false, - burstStartMs: nowMs, - burstFlips: 0, - pinnedUntilMs: null - }) + records.set(tabId, { parked, windowStartMs: nowMs, flips: 0, notified: false }) continue } if (parked === record.parked) { continue } - // Why: Date.now() jumps backwards on NTP/sleep-wake; treat any out-of-range - // elapsed value as a fresh window rather than trusting the delta. const elapsedMs = nowMs - record.windowStartMs if (elapsedMs >= flipWindowMs || elapsedMs < 0) { - resetFlipWindows(record, nowMs) - } - const burstElapsedMs = nowMs - record.burstStartMs - if (burstElapsedMs >= burstWindowMs || burstElapsedMs < 0) { - record.burstStartMs = nowMs - record.burstFlips = 0 + record.windowStartMs = nowMs + record.flips = 0 + record.notified = false } record.parked = parked record.flips += 1 - record.burstFlips += 1 - if (!isParkVerdictPinLive(record, nowMs) && record.burstFlips >= burstLimit) { - record.pinnedUntilMs = nowMs + flipWindowMs - recordRendererCrashBreadcrumb('terminal_park_verdict_churn', { - tabId, - trigger: 'burst', - flips: record.burstFlips, - elapsedMs: nowMs - record.burstStartMs, - windowMs: burstWindowMs, - pinnedForMs: flipWindowMs - }) - continue - } - // Why a live pin gates this: the burst crumb already reported the same - // window, so a second crumb would only double the volume the notice limit - // exists to keep down. - if (!isParkVerdictPinLive(record, nowMs) && !record.notified && record.flips >= noticeLimit) { + if (!record.notified && record.flips >= noticeLimit) { record.notified = true - // Why: flips is always exactly noticeLimit here, so elapsedMs is the only - // field that separates slow churn from a burst the damping already caught. recordRendererCrashBreadcrumb('terminal_park_verdict_churn', { tabId, - trigger: 'window', flips: record.flips, elapsedMs: nowMs - record.windowStartMs, windowMs: flipWindowMs diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-exit-leaf-resolution.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-exit-leaf-resolution.test.ts new file mode 100644 index 00000000000..3a268e8a519 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-exit-leaf-resolution.test.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/types' + +const WORKTREE_ID = 'repo::/worktree' +const OTHER_WORKTREE_ID = 'repo::/other-worktree' +const TAB_ID = 'tab-1' +const PTY_ID = `${WORKTREE_ID}@@session-1` +const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2` +const REPLACEMENT_PTY_ID = `${WORKTREE_ID}@@replacement` +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +type MockStoreState = { + tabsByWorktree: Record + terminalLayoutsByTabId: Record + runtimePaneTitlesByTabId: Record> + clearTabLaunchAgent: ReturnType + clearRuntimePaneTitle: ReturnType + setTabLayout: ReturnType + updateTabTitle: ReturnType +} + +let state: MockStoreState + +type ExitCallback = (code: number, context: { hadPrimary: boolean }) => void +const watcherTransport = vi.hoisted(() => ({ + exitCallbacks: new Map(), + discardPreHandlerPtyState: vi.fn() +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => state } })) +vi.mock('./parked-terminal-byte-watcher', () => ({ + startParkedTerminalByteWatcher: vi.fn(() => vi.fn()) +})) +vi.mock('./pty-dispatcher', () => ({ + subscribeToPtyExit: (ptyId: string, callback: ExitCallback) => { + watcherTransport.exitCallbacks.set(ptyId, callback) + return vi.fn() + } +})) +vi.mock('./pty-pre-handler-buffer', () => ({ + discardPreHandlerPtyState: watcherTransport.discardPreHandlerPtyState +})) +vi.mock('../terminal/terminal-tab-actions', () => ({ closeTerminalTab: vi.fn() })) + +import { collapseParkedExitedLeaf, startParkedPtyWatcher } from './terminal-parked-pty-watcher' +import { + captureParkedTerminalPaneCandidates, + capturedPanesByTabId, + parkedWatchersByTabId +} from './terminal-parked-watcher-registry' + +function splitLayout(ptyIdsByLeafId: Record): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId + } +} + +function capture(worktreeId: string, generation: number): void { + captureParkedTerminalPaneCandidates(TAB_ID, worktreeId, generation, [ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) +} + +function startFirstLeafWatcher(): void { + const entry = parkedWatchersByTabId.get(TAB_ID) + if (!entry) { + throw new Error('missing parked watcher entry') + } + entry.disposersByPtyId.set(SECOND_PTY_ID, vi.fn()) + startParkedPtyWatcher({ + worktreeId: WORKTREE_ID, + tab: { id: TAB_ID, ptyId: PTY_ID, generation: 1 }, + pane: { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + entry, + restoreTitleOnRegister: false, + restorePolicy: { sshParkingEnabled: true } + }) +} + +describe('parked exit leaf resolution', () => { + beforeEach(() => { + state = { + tabsByWorktree: { + [WORKTREE_ID]: [{ id: TAB_ID, ptyId: PTY_ID, generation: 1 }] + }, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Agent', 2: 'Shell' } }, + clearTabLaunchAgent: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + setTabLayout: vi.fn(), + updateTabTitle: vi.fn() + } + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: WORKTREE_ID, + tabPtyId: PTY_ID, + paneIdByPtyId: new Map([ + [PTY_ID, 1], + [SECOND_PTY_ID, 2] + ]), + disposersByPtyId: new Map() + }) + }) + + afterEach(() => { + capturedPanesByTabId.delete(TAB_ID) + parkedWatchersByTabId.delete(TAB_ID) + watcherTransport.exitCallbacks.clear() + vi.clearAllMocks() + }) + + it('does not detach a replacement occupant through the watcher leaf fallback', () => { + startFirstLeafWatcher() + captureParkedTerminalPaneCandidates(TAB_ID, OTHER_WORKTREE_ID, 9, [ + { ptyId: SECOND_PTY_ID, paneId: 8, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: PTY_ID, paneId: 9, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + state.terminalLayoutsByTabId[TAB_ID] = splitLayout({ + [LEAF_ID]: REPLACEMENT_PTY_ID, + [SECOND_LEAF_ID]: SECOND_PTY_ID + }) + + watcherTransport.exitCallbacks.get(PTY_ID)?.(0, { hadPrimary: false }) + + expect(state.setTabLayout).not.toHaveBeenCalled() + }) + + it('uses a current watcher leaf when the layout leaves it unbound', () => { + startFirstLeafWatcher() + state.terminalLayoutsByTabId[TAB_ID] = splitLayout({ + [SECOND_LEAF_ID]: SECOND_PTY_ID + }) + + watcherTransport.exitCallbacks.get(PTY_ID)?.(0, { hadPrimary: false }) + + expect(state.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: SECOND_LEAF_ID }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SECOND_LEAF_ID]: SECOND_PTY_ID } + }) + }) + + it('prefers a concurrent layout rebind over the watcher leaf', () => { + startFirstLeafWatcher() + state.terminalLayoutsByTabId[TAB_ID] = splitLayout({ + [LEAF_ID]: SECOND_PTY_ID, + [SECOND_LEAF_ID]: PTY_ID + }) + + watcherTransport.exitCallbacks.get(PTY_ID)?.(0, { hadPrimary: false }) + + expect(state.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: SECOND_PTY_ID } + }) + }) + + it.each(['generation', 'worktree'] as const)( + 'rejects a watcher leaf after its %s identity becomes stale', + (staleIdentity) => { + startFirstLeafWatcher() + if (staleIdentity === 'generation') { + state.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, ptyId: PTY_ID, generation: 2 }] + } else { + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: OTHER_WORKTREE_ID, + tabPtyId: PTY_ID, + paneIdByPtyId: new Map(), + disposersByPtyId: new Map() + }) + } + state.terminalLayoutsByTabId[TAB_ID] = splitLayout({ + [SECOND_LEAF_ID]: SECOND_PTY_ID + }) + + watcherTransport.exitCallbacks.get(PTY_ID)?.(0, { hadPrimary: false }) + + expect(state.setTabLayout).not.toHaveBeenCalled() + } + ) + + it('prefers the current layout binding over a stale captured leaf', () => { + capture(WORKTREE_ID, 1) + state.terminalLayoutsByTabId[TAB_ID] = splitLayout({ + [LEAF_ID]: SECOND_PTY_ID, + [SECOND_LEAF_ID]: PTY_ID + }) + + collapseParkedExitedLeaf(TAB_ID, PTY_ID) + + expect(state.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: SECOND_PTY_ID } + }) + }) + + it('does not detach a replacement occupant through the capture fallback', () => { + capture(WORKTREE_ID, 1) + state.terminalLayoutsByTabId[TAB_ID] = splitLayout({ + [LEAF_ID]: REPLACEMENT_PTY_ID, + [SECOND_LEAF_ID]: SECOND_PTY_ID + }) + + collapseParkedExitedLeaf(TAB_ID, PTY_ID) + + expect(state.setTabLayout).not.toHaveBeenCalled() + }) + + it('uses a current capture when its leaf is unbound in the layout', () => { + capture(WORKTREE_ID, 1) + state.terminalLayoutsByTabId[TAB_ID] = splitLayout({ + [SECOND_LEAF_ID]: SECOND_PTY_ID + }) + + collapseParkedExitedLeaf(TAB_ID, PTY_ID) + + expect(state.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: SECOND_LEAF_ID }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SECOND_LEAF_ID]: SECOND_PTY_ID } + }) + }) + + it.each(['generation', 'worktree'] as const)( + 'does not detach a leaf from a %s-stale capture', + (staleIdentity) => { + capture(staleIdentity === 'worktree' ? OTHER_WORKTREE_ID : WORKTREE_ID, 1) + if (staleIdentity === 'generation') { + state.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, ptyId: PTY_ID, generation: 2 }] + } + state.terminalLayoutsByTabId[TAB_ID] = splitLayout({ + [SECOND_LEAF_ID]: SECOND_PTY_ID + }) + + collapseParkedExitedLeaf(TAB_ID, PTY_ID) + + expect(state.setTabLayout).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher-adoption.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher-adoption.test.ts new file mode 100644 index 00000000000..8fa95309aeb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher-adoption.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const WORKTREE_ID = 'repo::/worktree' +const TAB_ID = 'tab-1' +const PTY_ID = `${WORKTREE_ID}@@session-1` +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +const disposeByteWatcher = vi.fn() +const startParkedTerminalByteWatcher = vi.fn( + (_options: ParkedTerminalByteWatcherOptions) => disposeByteWatcher +) +vi.mock('./parked-terminal-byte-watcher', () => ({ + startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) => + startParkedTerminalByteWatcher(options) +})) + +const unsubscribeExit = vi.fn() +let exitWatcher: ((code: number, context: { hadPrimary: boolean }) => void) | null = null +const subscribeToPtyExit = vi.fn( + ( + _ptyId: string, + watcher: (code: number, context: { hadPrimary: boolean }) => void, + _options?: { adoptPreHandlerExit?: boolean } + ) => { + exitWatcher = watcher + return unsubscribeExit + } +) +vi.mock('./pty-dispatcher', () => ({ + subscribeToPtyExit: ( + ptyId: string, + watcher: (code: number, context: { hadPrimary: boolean }) => void, + options?: { adoptPreHandlerExit?: boolean } + ) => subscribeToPtyExit(ptyId, watcher, options) +})) + +const storeState = { + runtimePaneTitlesByTabId: {} as Record>, + settings: null, + clearRuntimePaneTitle: vi.fn() +} +vi.mock('@/store', () => ({ + useAppStore: { getState: () => storeState } +})) + +import { startParkedPtyWatcher } from './terminal-parked-pty-watcher' + +describe('parked PTY watcher handoff adoption', () => { + afterEach(() => { + exitWatcher = null + vi.clearAllMocks() + }) + + function createEntry() { + return { + worktreeId: WORKTREE_ID, + tabPtyId: PTY_ID, + paneIdByPtyId: new Map(), + disposersByPtyId: new Map void>() + } + } + + it('opts the parked exit owner into buffered-exit adoption', () => { + const entry = createEntry() + + startParkedPtyWatcher({ + worktreeId: WORKTREE_ID, + tab: { id: TAB_ID, ptyId: PTY_ID, generation: 0 }, + pane: { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + entry, + restoreTitleOnRegister: false, + restorePolicy: { sshParkingEnabled: true } + }) + + expect(subscribeToPtyExit).toHaveBeenCalledWith(PTY_ID, expect.any(Function), { + adoptPreHandlerExit: true + }) + entry.disposersByPtyId.get(PTY_ID)?.() + expect(unsubscribeExit).toHaveBeenCalledOnce() + expect(disposeByteWatcher).toHaveBeenCalledOnce() + }) + + it('rolls back the byte watcher when exit subscription fails', () => { + const entry = createEntry() + entry.paneIdByPtyId.set(PTY_ID, 7) + subscribeToPtyExit.mockImplementationOnce(() => { + throw new Error('exit subscription failed') + }) + + expect(() => + startParkedPtyWatcher({ + worktreeId: WORKTREE_ID, + tab: { id: TAB_ID, ptyId: PTY_ID, generation: 0 }, + pane: { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + entry, + restoreTitleOnRegister: false, + restorePolicy: { sshParkingEnabled: true } + }) + ).toThrow('exit subscription failed') + + expect(disposeByteWatcher).toHaveBeenCalledOnce() + expect(entry.paneIdByPtyId.has(PTY_ID)).toBe(false) + expect(entry.disposersByPtyId.has(PTY_ID)).toBe(false) + }) + + it('detaches watcher ownership before adopted-exit store cleanup', () => { + const entry = createEntry() + startParkedPtyWatcher({ + worktreeId: WORKTREE_ID, + tab: { id: TAB_ID, ptyId: PTY_ID, generation: 0 }, + pane: { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + entry, + restoreTitleOnRegister: false, + restorePolicy: { sshParkingEnabled: true } + }) + storeState.clearRuntimePaneTitle.mockImplementationOnce(() => { + throw new Error('store cleanup failed') + }) + + expect(() => exitWatcher?.(0, { hadPrimary: false })).toThrow('store cleanup failed') + + expect(disposeByteWatcher).toHaveBeenCalledOnce() + expect(entry.disposersByPtyId.has(PTY_ID)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts index 4c091456e32..7b4b2261723 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts @@ -17,6 +17,8 @@ import { } from './terminal-pane-close-identity' import { capturedPanesByTabId, + markParkedTerminalWatcherOwnershipRestored, + notifyParkedTerminalWatcherOwnershipLoss, parkedWatchersByTabId, type ParkedTabWatcherEntry, type ParkedTerminalPaneCapture @@ -42,24 +44,33 @@ export function startParkedPtyWatcher(args: { ) { return } + let exitHandled = false const handlePtyExit = (_code: number, { hadPrimary }: { hadPrimary: boolean }): void => { + if (exitHandled) { + return + } + exitHandled = true + const hadSiblingWatcher = entry.disposersByPtyId.size > 1 + const dispose = entry.disposersByPtyId.get(ptyId) + entry.disposersByPtyId.delete(ptyId) + dispose?.() useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId) - if (entry.disposersByPtyId.size > 1) { + if (hadSiblingWatcher) { discardPreHandlerPtyState(ptyId) - collapseParkedExitedLeaf(tab.id, ptyId) - entry.disposersByPtyId.get(ptyId)?.() - entry.disposersByPtyId.delete(ptyId) + collapseParkedExitedLeaf(tab.id, ptyId, { + entry, + leafId: pane.leafId, + worktreeId, + generation: tab.generation ?? null + }) return } if (hadPrimary) { - entry.disposersByPtyId.get(ptyId)?.() - entry.disposersByPtyId.delete(ptyId) + notifyParkedTerminalWatcherOwnershipLoss(tab.id, entry, 'primary-exit-unowned') return } // Why: the empty entry prevents a pending pinned-close confirmation from restarting the dead PTY. - entry.disposersByPtyId.get(ptyId)?.() - entry.disposersByPtyId.delete(ptyId) closeTerminalTab(tab.id, { captureRecentlyClosed: false, hostCloseReason: 'pty-exit', @@ -70,7 +81,9 @@ export function startParkedPtyWatcher(args: { parkedWatchersByTabId.delete(tab.id) } }, - onCancel: () => {} + onCancel: () => { + notifyParkedTerminalWatcherOwnershipLoss(tab.id, entry, 'close-cancelled') + } }) } const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId] @@ -87,22 +100,105 @@ export function startParkedPtyWatcher(args: { sendRuntimePtyInput(useAppStore.getState().settings, ptyId, data) } }) - const unsubscribeExit = isRemoteRuntimePtyId(ptyId) - ? () => {} - : subscribeToPtyExit(ptyId, handlePtyExit) + let unsubscribeExit: () => void + try { + unsubscribeExit = isRemoteRuntimePtyId(ptyId) + ? () => {} + : subscribeToPtyExit(ptyId, handlePtyExit, { adoptPreHandlerExit: true }) + } catch (error) { + entry.paneIdByPtyId.delete(ptyId) + entry.disposersByPtyId.delete(ptyId) + try { + disposeWatcher() + } catch { + // Why: preserve the subscription failure that prevented watcher ownership. + } + throw error + } entry.paneIdByPtyId.set(ptyId, pane.paneId) entry.disposersByPtyId.set(ptyId, () => { unsubscribeExit() disposeWatcher() }) + markParkedTerminalWatcherOwnershipRestored(entry) } -export function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { +type ParkedExitedLeafWatcherFallback = { + entry: ParkedTabWatcherEntry + leafId: string + worktreeId: string + generation: number | null +} + +function resolveUnoccupiedParkedExitFallbackLeaf( + layout: ReturnType['terminalLayoutsByTabId'][string] | undefined, + leafId: string, + ptyId: string +): string | null { + const boundPtyId = layout?.ptyIdsByLeafId?.[leafId] + return boundPtyId === undefined || boundPtyId === ptyId ? leafId : null +} + +function isCurrentParkedExitedLeafWatcher( + state: ReturnType, + tabId: string, + watcher: ParkedExitedLeafWatcherFallback +): boolean { + const liveTab = (state.tabsByWorktree[watcher.worktreeId] ?? []).find( + (candidate) => candidate.id === tabId + ) + return ( + liveTab !== undefined && + parkedWatchersByTabId.get(tabId) === watcher.entry && + watcher.entry.worktreeId === watcher.worktreeId && + (liveTab.generation ?? null) === watcher.generation + ) +} + +function resolveParkedExitedLeafId( + state: ReturnType, + tabId: string, + ptyId: string, + watcher?: ParkedExitedLeafWatcherFallback +): string | null { + if (watcher && !isCurrentParkedExitedLeafWatcher(state, tabId, watcher)) { + return null + } + const layout = state.terminalLayoutsByTabId[tabId] + const boundLeafId = Object.entries(layout?.ptyIdsByLeafId ?? {}).find( + ([, boundPtyId]) => boundPtyId === ptyId + )?.[0] + if (boundLeafId) { + return boundLeafId + } + if (watcher) { + return resolveUnoccupiedParkedExitFallbackLeaf(layout, watcher.leafId, ptyId) + } + const entry = parkedWatchersByTabId.get(tabId) + const capture = capturedPanesByTabId.get(tabId) + if (!entry || !capture || capture.worktreeId !== entry.worktreeId) { + return null + } + const liveTab = (state.tabsByWorktree[entry.worktreeId] ?? []).find( + (candidate) => candidate.id === tabId + ) + if (!liveTab || capture.generation !== (liveTab.generation ?? null)) { + return null + } + const capturedLeafId = capture.panes.find((pane) => pane.ptyId === ptyId)?.leafId + return capturedLeafId + ? resolveUnoccupiedParkedExitFallbackLeaf(layout, capturedLeafId, ptyId) + : null +} + +export function collapseParkedExitedLeaf( + tabId: string, + ptyId: string, + watcher?: ParkedExitedLeafWatcherFallback +): void { const state = useAppStore.getState() const layout = state.terminalLayoutsByTabId[tabId] - const leafId = - capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ?? - Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0] + const leafId = resolveParkedExitedLeafId(state, tabId, ptyId, watcher) if (!leafId) { return } diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index 2e9b3a52ef2..ffcb98f2510 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -115,15 +115,29 @@ import { shouldDeferParkedPtyExitTabClose, syncParkedTerminalTabWatchers } from './terminal-parked-tab-watchers' +import { + subscribeParkedTerminalWatcherOwnershipLoss, + type ParkedTerminalWatcherOwnershipLossEvent +} from './terminal-parked-watcher-registry' const ptyWrite = vi.fn() const originalWindow = (globalThis as { window?: unknown }).window +const ownershipLossUnsubscribers: (() => void)[] = [] + +function observeOwnershipLoss(): ReturnType< + typeof vi.fn<(event: ParkedTerminalWatcherOwnershipLossEvent) => void> +> { + const listener = vi.fn<(event: ParkedTerminalWatcherOwnershipLossEvent) => void>() + ownershipLossUnsubscribers.push(subscribeParkedTerminalWatcherOwnershipLoss(listener)) + return listener +} function capturePanes( panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[], args?: { tabId?: string; worktreeId?: string } ): void { - captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes) + const { tabId = TAB_ID, worktreeId = WORKTREE_ID } = args ?? {} + captureParkedTerminalPaneCandidates(tabId, worktreeId, null, panes) } function syncParked(args?: { @@ -145,7 +159,7 @@ function syncParked(args?: { describe('terminal-parked-tab-watchers', () => { beforeEach(async () => { mockStoreState = { - tabsByWorktree: {}, + tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID, ptyId: PTY_ID }] }, terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {}, settings: null, @@ -169,6 +183,9 @@ describe('terminal-parked-tab-watchers', () => { pruneParkedTerminalWatchers(new Set()) startedWatchers.length = 0 exitSubscriptions.length = 0 + for (const unsubscribe of ownershipLossUnsubscribers.splice(0)) { + unsubscribe() + } vi.clearAllMocks() clearTerminalProviderSnapshotCapabilities() ;(globalThis as { window?: unknown }).window = originalWindow @@ -386,14 +403,22 @@ describe('terminal-parked-tab-watchers', () => { }) it('retains the buffered exit and empty registry entry when pinned close is cancelled', () => { + const ownershipLoss = observeOwnershipLoss() capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) syncParked() exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false }) const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions + expect(ownershipLoss).not.toHaveBeenCalled() options.onCancel?.() syncParked({ parkedTabIds: [] }) + expect(ownershipLoss).toHaveBeenCalledOnce() + expect(ownershipLoss).toHaveBeenCalledWith({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + reason: 'close-cancelled' + }) expect(consumePreHandlerPtyState).not.toHaveBeenCalled() expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) @@ -417,6 +442,7 @@ describe('terminal-parked-tab-watchers', () => { }) it('does not queue a second close when a retained primary handled the parked exit', () => { + const ownershipLoss = observeOwnershipLoss() capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) syncParked() @@ -425,6 +451,22 @@ describe('terminal-parked-tab-watchers', () => { expect(closeTerminalTab).not.toHaveBeenCalled() expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + expect(ownershipLoss).toHaveBeenCalledWith({ + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + reason: 'primary-exit-unowned' + }) + }) + + it('does not report intentional shutdown suspension as ownership loss', () => { + const ownershipLoss = observeOwnershipLoss() + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + disposeParkedTerminalWatchersForPtyIds([PTY_ID]) + + expect(ownershipLoss).not.toHaveBeenCalled() + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) }) it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 42de06cac37..9f04705f9c8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -5,30 +5,16 @@ * parsers. This module runs a pane-less byte watcher per PTY while parked and * disposes them on reveal, tab close, PTY exit, or worktree teardown. */ -import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { useAppStore } from '@/store' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' -import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' -import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' -import { terminalProviderHasAuthoritativeSnapshot } from '../terminal/terminal-provider-snapshot-capability' +import type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' +import type { ParkedTerminalPtyEligibility } from './terminal-parked-watcher-coverage-plan' +import { collapseParkedExitedLeaf } from './terminal-parked-pty-watcher' +import { parkedWatchersByTabId } from './terminal-parked-watcher-registry' import { - isParkRestorableTerminalPty, - selectPairedRuntimeParkingEnvironmentIds, - type TerminalParkRestorePolicy -} from './terminal-hidden-view-parking' -import { - reconcileParkedWatcherPtyIds, - resolveParkedTerminalPaneCandidates, - type ParkableTerminalTabModel -} from './terminal-parked-watcher-reconciliation' -import { collapseParkedExitedLeaf, startParkedPtyWatcher } from './terminal-parked-pty-watcher' -import { - capturedPanesByTabId, - disposeParkedTabWatchers, - parkedWatchersByTabId, - type ParkedTabWatcherEntry, - type ParkedTerminalPaneCapture -} from './terminal-parked-watcher-registry' + planParkedTerminalTabWatcherCoverage, + syncParkedTerminalTabWatchers, + syncParkedTerminalTabWatchersWithAcknowledgements +} from './terminal-parked-watcher-acknowledgement' // Why: re-export so callers keep one import surface; the registry split only breaks the store-slice import cycle. export { @@ -39,37 +25,28 @@ export { disposeParkedTerminalWatchersForWorktree, getParkedTerminalWatcherTabIds, pruneParkedTerminalWatchers, + subscribeParkedTerminalWatcherOwnershipLoss, terminalWatcherLiveWorkspaceIds } from './terminal-parked-watcher-registry' -export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' +export type { + ParkedTerminalPaneCapture, + ParkedTerminalWatcherOwnershipLossEvent +} from './terminal-parked-watcher-registry' export { fallbackParkedPaneCandidates, resolveParkedTerminalPaneCandidates } from './terminal-parked-watcher-reconciliation' export type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' -export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean - -const allowOrdinaryParkRestore = (ptyId: string): boolean => - isRemoteRuntimePtyId(ptyId) || - parseAppSshPtyId(ptyId) !== null || - terminalProviderHasAuthoritativeSnapshot(ptyId) - -// Why: fact-mode watchers work for any pty whose bytes transit local main — -// SSH included — so watcher coverage follows the park-restore policy, not the -// stricter daemon-snapshot predicate. -function parkRestorePolicyFromState(state: { - settings: { terminalSshViewParking?: boolean } | null - runtimeStatusByEnvironmentId: ReadonlyMap< - string, - { status: { capabilities?: readonly string[] } | null | undefined } - > -}): TerminalParkRestorePolicy { - return { - sshParkingEnabled: state.settings?.terminalSshViewParking !== false, - pairedRuntimeParkingEnvironmentIds: selectPairedRuntimeParkingEnvironmentIds( - state.runtimeStatusByEnvironmentId - ) - } +export type { ParkedTerminalPtyEligibility } from './terminal-parked-watcher-coverage-plan' +export type { TerminalParkedWatcherCoveragePlan } from './terminal-parked-watcher-coverage-plan' +export type { + ParkedTerminalTabWatcherSyncArgs, + TerminalParkedWatcherSyncAcknowledgement +} from './terminal-park-episode-lease' +export { + planParkedTerminalTabWatcherCoverage, + syncParkedTerminalTabWatchers, + syncParkedTerminalTabWatchersWithAcknowledgements } /** @@ -80,122 +57,10 @@ function parkRestorePolicyFromState(state: { export function canWatcherCoverParkedTerminalTab( worktreeId: string, tab: ParkableTerminalTabModel, - // Why: paired and SSH restore through their own policy; local parking must not - // unmount a pane whose preserved daemon can only return a lossy snapshot. - isPtyEligible: ParkedTerminalPtyEligibility = allowOrdinaryParkRestore + isPtyEligible?: ParkedTerminalPtyEligibility ): boolean { - const state = useAppStore.getState() - const panes = resolveParkedTerminalPaneCandidates(tab, state) - const restorePolicy = parkRestorePolicyFromState(state) return ( - panes.length > 0 && - panes.every( - (pane) => - pane.ptyId !== null && - isTerminalLeafId(pane.leafId) && - isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) && - isPtyEligible(pane.ptyId) - ) - ) -} - -function startParkedTabWatchers( - worktreeId: string, - tab: ParkableTerminalTabModel, - restoreTitleOnRegister: boolean -): void { - const entry: ParkedTabWatcherEntry = { - worktreeId, - tabPtyId: tab.ptyId, - paneIdByPtyId: new Map(), - disposersByPtyId: new Map() - } - parkedWatchersByTabId.set(tab.id, entry) - const restorePolicy = parkRestorePolicyFromState(useAppStore.getState()) - for (const pane of resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())) { - startParkedPtyWatcher({ - worktreeId, - tab, - pane, - entry, - restoreTitleOnRegister, - restorePolicy - }) - } -} - -function reconcileParkedTabWatchers( - worktreeId: string, - tab: ParkableTerminalTabModel, - entry: ParkedTabWatcherEntry, - restoreTitleOnRegister: boolean -): void { - const state = useAppStore.getState() - const expectedPanes = watchablePanes(worktreeId, tab) - const expectedPtyIds = new Set(expectedPanes.keys()) - const reconciliation = reconcileParkedWatcherPtyIds({ - currentTabPtyId: tab.ptyId, - entryTabPtyId: entry.tabPtyId, - paneIdByPtyId: entry.paneIdByPtyId, - expectedPtyIds - }) - if (reconciliation.restartAll) { - const retainedTitles = reconciliation.retainedPtyIds.flatMap((ptyId) => { - const paneId = entry.paneIdByPtyId.get(ptyId) - const title = - paneId === undefined ? undefined : state.runtimePaneTitlesByTabId[tab.id]?.[paneId] - return paneId !== undefined && title !== undefined ? [{ paneId, title }] : [] - }) - for (const paneId of reconciliation.retiredPaneIds) { - state.clearRuntimePaneTitle(tab.id, paneId) - } - disposeParkedTabWatchers(tab.id) - for (const { paneId, title } of retainedTitles) { - useAppStore.getState().setRuntimePaneTitle(tab.id, paneId, title) - } - startParkedTabWatchers(worktreeId, tab, restoreTitleOnRegister) - return - } - for (const [ptyId, paneId] of Array.from(entry.paneIdByPtyId)) { - if (expectedPtyIds.has(ptyId)) { - continue - } - entry.paneIdByPtyId.delete(ptyId) - const dispose = entry.disposersByPtyId.get(ptyId) - entry.disposersByPtyId.delete(ptyId) - dispose?.() - state.clearRuntimePaneTitle(tab.id, paneId) - } - const restorePolicy = parkRestorePolicyFromState(useAppStore.getState()) - for (const ptyId of reconciliation.addedPtyIds) { - const pane = expectedPanes.get(ptyId) - if (pane) { - startParkedPtyWatcher({ - worktreeId, - tab, - pane, - entry, - restoreTitleOnRegister, - restorePolicy - }) - } - } -} - -function watchablePanes( - worktreeId: string, - tab: ParkableTerminalTabModel -): Map { - const state = useAppStore.getState() - const restorePolicy = parkRestorePolicyFromState(state) - return new Map( - resolveParkedTerminalPaneCandidates(tab, state).flatMap((pane) => - pane.ptyId && - isTerminalLeafId(pane.leafId) && - isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) - ? [[pane.ptyId, pane] as const] - : [] - ) + planParkedTerminalTabWatcherCoverage(worktreeId, tab, { isPtyEligible }).status === 'covered' ) } @@ -231,62 +96,3 @@ export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): } return defer } - -function disposeClosedParkedTabWatchers( - tabId: string, - entry: { paneIdByPtyId: ReadonlyMap } -): void { - // Why: a queued pinned-close may close the tab first, leaving no pane to drain retained frames. - for (const ptyId of entry.paneIdByPtyId.keys()) { - discardPreHandlerPtyState(ptyId) - } - for (const paneId of entry.paneIdByPtyId.values()) { - useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) - } - disposeParkedTabWatchers(tabId) -} - -/** - * Reconciles watchers for one worktree against its rendered parked set. - * Run from an effect keyed on committed render state so disposal shares the - * reveal remount's flush (before PTY data IPC) and start follows the park unmount. - */ -export function syncParkedTerminalTabWatchers(args: { - worktreeId: string - tabs: readonly ParkableTerminalTabModel[] - parkedTabIds: ReadonlySet - /** Parked-equivalent tabs whose pane has not restored the current title. */ - restoreTitleOnStartTabIds?: ReadonlySet -}): void { - const liveTabIds = new Set(args.tabs.map((tab) => tab.id)) - for (const [tabId, entry] of parkedWatchersByTabId) { - if (entry.worktreeId !== args.worktreeId) { - continue - } - if (!liveTabIds.has(tabId)) { - disposeClosedParkedTabWatchers(tabId, entry) - continue - } - if (!args.parkedTabIds.has(tabId) && entry.disposersByPtyId.size > 0) { - disposeParkedTabWatchers(tabId) - } - } - // Why: closed tabs never park/reveal again; drop captures to keep the registry bounded. - for (const [tabId, capture] of capturedPanesByTabId) { - if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) { - capturedPanesByTabId.delete(tabId) - } - } - for (const tab of args.tabs) { - if (!args.parkedTabIds.has(tab.id)) { - continue - } - const entry = parkedWatchersByTabId.get(tab.id) - const restoreTitleOnRegister = args.restoreTitleOnStartTabIds?.has(tab.id) === true - if (entry) { - reconcileParkedTabWatchers(args.worktreeId, tab, entry, restoreTitleOnRegister) - } else { - startParkedTabWatchers(args.worktreeId, tab, restoreTitleOnRegister) - } - } -} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-title-authority.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-title-authority.test.ts new file mode 100644 index 00000000000..c2535110c43 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-title-authority.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' +import { parkedTerminalLeafDrivesTabTitle } from './terminal-parked-title-authority' + +const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +describe('parked terminal title authority', () => { + it('promotes the surviving watcher when the active split leaf exits', () => { + const collapsed = detachTerminalLayoutLeaf( + { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: FIRST_LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: FIRST_LEAF_ID, + expandedLeafId: null + }, + FIRST_LEAF_ID + ) + if (!collapsed) { + throw new Error('expected split collapse') + } + + expect( + parkedTerminalLeafDrivesTabTitle({ + activeLeafId: collapsed.sourceLayout.activeLeafId, + leafId: SECOND_LEAF_ID, + capturedAuthority: false + }) + ).toBe(true) + }) + + it('lets current layout authority override stale captured authority', () => { + expect( + parkedTerminalLeafDrivesTabTitle({ + activeLeafId: SECOND_LEAF_ID, + leafId: FIRST_LEAF_ID, + capturedAuthority: true + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-title-authority.ts b/src/renderer/src/components/terminal-pane/terminal-parked-title-authority.ts new file mode 100644 index 00000000000..044f270e712 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-title-authority.ts @@ -0,0 +1,7 @@ +export function parkedTerminalLeafDrivesTabTitle(args: { + activeLeafId: string | null | undefined + leafId: string + capturedAuthority: boolean +}): boolean { + return args.activeLeafId ? args.activeLeafId === args.leafId : args.capturedAuthority +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-acknowledgement.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-acknowledgement.test.ts new file mode 100644 index 00000000000..e15af2f8f74 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-acknowledgement.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + ParkedTabWatcherEntry, + ParkedTerminalPaneCapture +} from './terminal-parked-watcher-registry' + +const WORKTREE_ID = 'repo::/worktree' +const TAB_ID = 'tab-1' +const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const FIRST_PTY_ID = 'ssh:connection-1@@pty-1' +const SECOND_PTY_ID = 'ssh:connection-1@@pty-2' + +const watcherMock = vi.hoisted(() => ({ + mode: 'complete' as 'complete' | 'partial' | 'throw', + starts: [] as { ptyId: string; dispose: ReturnType }[] +})) + +vi.mock('./terminal-parked-pty-watcher', () => ({ + collapseParkedExitedLeaf: vi.fn(), + startParkedPtyWatcher: (args: { + pane: ParkedTerminalPaneCapture + entry: ParkedTabWatcherEntry + }) => { + if (watcherMock.mode === 'throw') { + throw new Error('watcher start failed') + } + if (!args.pane.ptyId || (watcherMock.mode === 'partial' && args.pane.ptyId === SECOND_PTY_ID)) { + return + } + const dispose = vi.fn() + watcherMock.starts.push({ ptyId: args.pane.ptyId, dispose }) + args.entry.paneIdByPtyId.set(args.pane.ptyId, args.pane.paneId) + args.entry.disposersByPtyId.set(args.pane.ptyId, dispose) + } +})) + +vi.mock('./pty-pre-handler-buffer', () => ({ discardPreHandlerPtyState: vi.fn() })) + +type MockTab = { id: string; ptyId: string | null; generation?: number } +type MockStoreState = { + tabsByWorktree: Record + terminalLayoutsByTabId: Record + runtimePaneTitlesByTabId: Record> + settings: { terminalSshViewParking?: boolean } | null + runtimeStatusByEnvironmentId: Map + clearRuntimePaneTitle: ReturnType + setRuntimePaneTitle: ReturnType +} + +let state: MockStoreState + +vi.mock('@/store', () => ({ useAppStore: { getState: () => state } })) + +import { + captureParkedTerminalPaneCandidates, + getParkedTerminalWatcherTabIds, + planParkedTerminalTabWatcherCoverage, + pruneParkedTerminalWatchers, + syncParkedTerminalTabWatchersWithAcknowledgements +} from './terminal-parked-tab-watchers' + +function capture(panes?: ParkedTerminalPaneCapture[]): void { + captureParkedTerminalPaneCandidates( + TAB_ID, + WORKTREE_ID, + 1, + panes ?? [ + { leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 1, drivesTabTitle: true }, + { leafId: SECOND_LEAF_ID, ptyId: SECOND_PTY_ID, paneId: 2, drivesTabTitle: false } + ] + ) +} + +function planAndSync() { + const tab = state.tabsByWorktree[WORKTREE_ID][0] + const plan = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab) + const acknowledgements = syncParkedTerminalTabWatchersWithAcknowledgements({ + worktreeId: WORKTREE_ID, + tabs: [tab], + parkedTabIds: new Set([TAB_ID]), + coveragePlansByTabId: new Map([[TAB_ID, plan]]) + }) + return { plan, acknowledgement: acknowledgements[0] } +} + +describe('parked watcher sync acknowledgement', () => { + beforeEach(() => { + state = { + tabsByWorktree: { + [WORKTREE_ID]: [{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 }] + }, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + settings: null, + runtimeStatusByEnvironmentId: new Map(), + clearRuntimePaneTitle: vi.fn(), + setRuntimePaneTitle: vi.fn() + } + watcherMock.mode = 'complete' + watcherMock.starts.length = 0 + capture() + }) + + afterEach(() => { + pruneParkedTerminalWatchers(new Set()) + vi.clearAllMocks() + }) + + it('acknowledges only registry-committed coverage for every planned PTY', () => { + const { plan, acknowledgement } = planAndSync() + + expect(plan.status).toBe('covered') + expect(acknowledgement).toEqual({ + status: 'covering', + tabId: TAB_ID, + materialKey: plan.materialKey, + watchedPtyIds: [FIRST_PTY_ID, SECOND_PTY_ID] + }) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('returns failure and rolls back a partial watcher start', () => { + watcherMock.mode = 'partial' + + const { acknowledgement } = planAndSync() + + expect(acknowledgement).toMatchObject({ + status: 'failed', + reason: 'watcher-coverage-incomplete', + watchedPtyIds: [FIRST_PTY_ID] + }) + expect(watcherMock.starts[0].dispose).toHaveBeenCalledOnce() + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('turns a thrown watcher start into an effect-safe failure result', () => { + watcherMock.mode = 'throw' + + expect(planAndSync().acknowledgement).toMatchObject({ + status: 'failed', + reason: 'watcher-start-failed' + }) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('rejects a same-PTY-set leaf swap against the requested material plan', () => { + const tab = state.tabsByWorktree[WORKTREE_ID][0] + const requested = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab) + capture([ + { leafId: FIRST_LEAF_ID, ptyId: SECOND_PTY_ID, paneId: 7, drivesTabTitle: true }, + { leafId: SECOND_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 8, drivesTabTitle: false } + ]) + + const [acknowledgement] = syncParkedTerminalTabWatchersWithAcknowledgements({ + worktreeId: WORKTREE_ID, + tabs: [tab], + parkedTabIds: new Set([TAB_ID]), + coveragePlansByTabId: new Map([[TAB_ID, requested]]) + }) + + expect(acknowledgement).toMatchObject({ status: 'failed', reason: 'material-changed' }) + expect(watcherMock.starts).toEqual([]) + }) + + it('restarts established watchers when a new plan swaps the same PTY set', () => { + planAndSync() + const originalWatchers = watcherMock.starts.slice() + capture([ + { leafId: FIRST_LEAF_ID, ptyId: SECOND_PTY_ID, paneId: 7, drivesTabTitle: true }, + { leafId: SECOND_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 8, drivesTabTitle: false } + ]) + + const { acknowledgement } = planAndSync() + + expect(acknowledgement.status).toBe('covering') + expect(watcherMock.starts).toHaveLength(4) + expect(originalWatchers.every(({ dispose }) => dispose.mock.calls.length === 1)).toBe(true) + }) + + it('rejects a stale tab generation or primary PTY before watcher startup', () => { + const tab = state.tabsByWorktree[WORKTREE_ID][0] + const requested = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab) + state.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, ptyId: SECOND_PTY_ID, generation: 2 }] + + const [acknowledgement] = syncParkedTerminalTabWatchersWithAcknowledgements({ + worktreeId: WORKTREE_ID, + tabs: [tab], + parkedTabIds: new Set([TAB_ID]), + coveragePlansByTabId: new Map([[TAB_ID, requested]]) + }) + + expect(acknowledgement).toMatchObject({ status: 'failed', reason: 'material-changed' }) + expect(watcherMock.starts).toEqual([]) + }) + + it('keeps force-parking explicitly best-effort for an uncovered plan', () => { + const tab = state.tabsByWorktree[WORKTREE_ID][0] + const plan = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab, { + isPtyEligible: () => false + }) + + const [acknowledgement] = syncParkedTerminalTabWatchersWithAcknowledgements({ + worktreeId: WORKTREE_ID, + tabs: [tab], + parkedTabIds: new Set([TAB_ID]), + coveragePlansByTabId: new Map([[TAB_ID, plan]]), + forcedTabIds: new Set([TAB_ID]) + }) + + expect(plan.status).toBe('blocked') + expect(acknowledgement).toMatchObject({ + status: 'forced', + materialKey: plan.materialKey, + watchedPtyIds: [FIRST_PTY_ID, SECOND_PTY_ID] + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-acknowledgement.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-acknowledgement.ts new file mode 100644 index 00000000000..bac6ba5b08e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-acknowledgement.ts @@ -0,0 +1,259 @@ +/** Commits planned watcher handoffs and reports their exact coverage. */ +import { useAppStore } from '@/store' +import { getTerminalProviderSnapshotCapabilityState } from '../terminal/terminal-provider-snapshot-capability' +import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' +import { + parkedWatcherRestorePolicyFromState, + reconcileParkedTabWatchers, + resolveParkedTerminalPaneMaterialBindings, + startParkedTabWatchers, + type ParkableTerminalTabModel +} from './terminal-parked-watcher-reconciliation' +import { + createTerminalParkedWatcherCoveragePlan, + type ParkedTerminalPtyEligibility, + type TerminalParkedWatcherCoveragePlan, + type TerminalParkedWatcherPlanGeneration +} from './terminal-parked-watcher-coverage-plan' +import { + terminalParkedWatcherPlanPtyIds, + type ParkedTerminalTabWatcherSyncArgs, + type TerminalParkedWatcherSyncAcknowledgement, + type TerminalParkedWatcherSyncFailureReason +} from './terminal-park-episode-lease' +import { + capturedPanesByTabId, + disposeParkedTabWatchers, + parkedWatchersByTabId, + type ParkedTabWatcherEntry +} from './terminal-parked-watcher-registry' + +const materialKeyByWatcherEntry = new WeakMap() + +export function planParkedTerminalTabWatcherCoverage( + worktreeId: string, + tab: ParkableTerminalTabModel, + options?: { + generation?: TerminalParkedWatcherPlanGeneration + isPtyEligible?: ParkedTerminalPtyEligibility + } +): TerminalParkedWatcherCoveragePlan { + const state = useAppStore.getState() + return createTerminalParkedWatcherCoveragePlan({ + worktreeId, + tab, + panes: resolveParkedTerminalPaneMaterialBindings(worktreeId, tab, state), + restorePolicy: parkedWatcherRestorePolicyFromState(state), + providerSnapshotCapability: getTerminalProviderSnapshotCapabilityState, + generation: options?.generation ?? tab.generation ?? null, + ...(options?.isPtyEligible ? { isPtyEligible: options.isPtyEligible } : {}) + }) +} + +function disposeClosedParkedTabWatchers( + tabId: string, + entry: { paneIdByPtyId: ReadonlyMap } +): void { + // Why: a queued pinned-close may close the tab first, leaving no pane to drain retained frames. + for (const ptyId of entry.paneIdByPtyId.keys()) { + discardPreHandlerPtyState(ptyId) + } + for (const paneId of entry.paneIdByPtyId.values()) { + useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) + } + disposeParkedTabWatchers(tabId) +} + +function failedWatcherSync( + tabId: string, + plan: TerminalParkedWatcherCoveragePlan | undefined, + reason: TerminalParkedWatcherSyncFailureReason, + watchedPtyIds: readonly string[] = [] +): TerminalParkedWatcherSyncAcknowledgement { + disposeParkedTabWatchers(tabId) + return { + status: 'failed', + tabId, + materialKey: plan?.materialKey ?? null, + reason, + expectedPtyIds: terminalParkedWatcherPlanPtyIds(plan), + watchedPtyIds + } +} + +function startOrReconcileParkedTabWatchers(args: { + worktreeId: string + tab: ParkableTerminalTabModel + restoreTitleOnRegister: boolean +}): void { + const entry = parkedWatchersByTabId.get(args.tab.id) + if (entry) { + reconcileParkedTabWatchers(args.worktreeId, args.tab, entry, args.restoreTitleOnRegister) + } else { + startParkedTabWatchers(args.worktreeId, args.tab, args.restoreTitleOnRegister) + } +} + +function restartWatchersForChangedMaterial(tabId: string, materialKey: string): void { + const entry = parkedWatchersByTabId.get(tabId) + if (entry && materialKeyByWatcherEntry.get(entry) !== materialKey) { + disposeParkedTabWatchers(tabId) + } +} + +function syncPlannedParkedTabWatchers(args: { + worktreeId: string + tab: ParkableTerminalTabModel + plan?: TerminalParkedWatcherCoveragePlan + restoreTitleOnRegister: boolean +}): TerminalParkedWatcherSyncAcknowledgement { + const { tab, plan } = args + if (!plan) { + return failedWatcherSync(tab.id, plan, 'coverage-plan-missing') + } + if (plan.status !== 'covered') { + return failedWatcherSync(tab.id, plan, 'coverage-plan-not-covered') + } + const liveTab = (useAppStore.getState().tabsByWorktree[args.worktreeId] ?? []).find( + (candidate) => candidate.id === tab.id + ) + if ( + !liveTab || + liveTab.ptyId !== plan.tabPtyId || + (liveTab.generation ?? null) !== plan.generation || + plan.worktreeId !== args.worktreeId + ) { + return failedWatcherSync(tab.id, plan, 'material-changed') + } + const currentPlan = planParkedTerminalTabWatcherCoverage(args.worktreeId, liveTab) + if (currentPlan.status !== 'covered' || currentPlan.materialKey !== plan.materialKey) { + return failedWatcherSync(tab.id, plan, 'material-changed') + } + try { + restartWatchersForChangedMaterial(tab.id, plan.materialKey) + startOrReconcileParkedTabWatchers({ ...args, tab: liveTab }) + } catch { + return failedWatcherSync(tab.id, plan, 'watcher-start-failed') + } + const entry = parkedWatchersByTabId.get(tab.id) + const watchedPtyIds = Array.from(entry?.disposersByPtyId.keys() ?? []).sort((left, right) => + left.localeCompare(right) + ) + const expectedPtyIds = terminalParkedWatcherPlanPtyIds(plan) + if ( + watchedPtyIds.length !== expectedPtyIds.length || + expectedPtyIds.some( + (ptyId, index) => watchedPtyIds[index] !== ptyId || !entry?.paneIdByPtyId.has(ptyId) + ) + ) { + return failedWatcherSync(tab.id, plan, 'watcher-coverage-incomplete', watchedPtyIds) + } + if (entry) { + materialKeyByWatcherEntry.set(entry, plan.materialKey) + } + return { status: 'covering', tabId: tab.id, materialKey: plan.materialKey, watchedPtyIds } +} + +function syncForcedParkedTabWatchers(args: { + worktreeId: string + tab: ParkableTerminalTabModel + plan?: TerminalParkedWatcherCoveragePlan + restoreTitleOnRegister: boolean +}): TerminalParkedWatcherSyncAcknowledgement { + try { + if (args.plan) { + restartWatchersForChangedMaterial(args.tab.id, args.plan.materialKey) + } + startOrReconcileParkedTabWatchers(args) + } catch { + return failedWatcherSync(args.tab.id, args.plan, 'watcher-start-failed') + } + const entry = parkedWatchersByTabId.get(args.tab.id) + const watchedPtyIds = Array.from(entry?.disposersByPtyId.keys() ?? []).sort((left, right) => + left.localeCompare(right) + ) + if (entry && args.plan) { + materialKeyByWatcherEntry.set(entry, args.plan.materialKey) + } + return { + status: 'forced', + tabId: args.tab.id, + materialKey: args.plan?.materialKey ?? null, + watchedPtyIds + } +} + +function syncParkedTerminalTabWatchersInternal( + args: ParkedTerminalTabWatcherSyncArgs, + coveragePlansByTabId?: ReadonlyMap, + forcedTabIds?: ReadonlySet +): TerminalParkedWatcherSyncAcknowledgement[] { + const acknowledgements: TerminalParkedWatcherSyncAcknowledgement[] = [] + const liveTabIds = new Set(args.tabs.map((tab) => tab.id)) + for (const [tabId, entry] of parkedWatchersByTabId) { + if (entry.worktreeId !== args.worktreeId) { + continue + } + if (!liveTabIds.has(tabId)) { + disposeClosedParkedTabWatchers(tabId, entry) + continue + } + if (!args.parkedTabIds.has(tabId) && entry.disposersByPtyId.size > 0) { + disposeParkedTabWatchers(tabId) + } + } + // Why: closed tabs never park/reveal again; drop captures to keep the registry bounded. + for (const [tabId, capture] of capturedPanesByTabId) { + if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) { + capturedPanesByTabId.delete(tabId) + } + } + for (const tab of args.tabs) { + if (!args.parkedTabIds.has(tab.id)) { + continue + } + const entry = parkedWatchersByTabId.get(tab.id) + const restoreTitleOnRegister = args.restoreTitleOnStartTabIds?.has(tab.id) === true + if (coveragePlansByTabId) { + if (forcedTabIds?.has(tab.id)) { + acknowledgements.push( + syncForcedParkedTabWatchers({ + worktreeId: args.worktreeId, + tab, + plan: coveragePlansByTabId.get(tab.id), + restoreTitleOnRegister + }) + ) + continue + } + acknowledgements.push( + syncPlannedParkedTabWatchers({ + worktreeId: args.worktreeId, + tab, + plan: coveragePlansByTabId.get(tab.id), + restoreTitleOnRegister + }) + ) + continue + } + if (entry) { + reconcileParkedTabWatchers(args.worktreeId, tab, entry, restoreTitleOnRegister) + } else { + startParkedTabWatchers(args.worktreeId, tab, restoreTitleOnRegister) + } + } + return acknowledgements +} + +export function syncParkedTerminalTabWatchers(args: ParkedTerminalTabWatcherSyncArgs): void { + syncParkedTerminalTabWatchersInternal(args) +} + +export function syncParkedTerminalTabWatchersWithAcknowledgements( + args: ParkedTerminalTabWatcherSyncArgs & { + coveragePlansByTabId: ReadonlyMap + forcedTabIds?: ReadonlySet + } +): TerminalParkedWatcherSyncAcknowledgement[] { + return syncParkedTerminalTabWatchersInternal(args, args.coveragePlansByTabId, args.forcedTabIds) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-coverage-plan.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-coverage-plan.test.ts new file mode 100644 index 00000000000..0691507ff71 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-coverage-plan.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { createTerminalParkedWatcherCoveragePlan } from './terminal-parked-watcher-coverage-plan' + +const WORKTREE_ID = 'repo::/worktree' +const TAB_ID = 'tab-1' +const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const FIRST_PTY_ID = `${WORKTREE_ID}@@session-1` +const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2` + +function createPlan(args?: { + panes?: readonly { leafId: string; ptyId: string | null }[] + generation?: number + capability?: 'pending' | 'authoritative' | 'unavailable' + sshParkingEnabled?: boolean + pairedEnvironments?: readonly string[] +}) { + return createTerminalParkedWatcherCoveragePlan({ + worktreeId: WORKTREE_ID, + tab: { id: TAB_ID, ptyId: FIRST_PTY_ID, generation: args?.generation ?? 1 }, + panes: args?.panes ?? [{ leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID }], + restorePolicy: { + sshParkingEnabled: args?.sshParkingEnabled ?? true, + pairedRuntimeParkingEnvironmentIds: new Set(args?.pairedEnvironments ?? []) + }, + providerSnapshotCapability: () => args?.capability ?? 'authoritative' + }) +} + +describe('terminal parked watcher coverage plan', () => { + it('ignores pane order and mount-only pane metadata', () => { + const mountedPanes = [ + { + leafId: FIRST_LEAF_ID, + ptyId: FIRST_PTY_ID, + paneId: 1, + drivesTabTitle: true + }, + { + leafId: SECOND_LEAF_ID, + ptyId: SECOND_PTY_ID, + paneId: 2, + drivesTabTitle: false + } + ] + const recapturedPanes = [ + { ...mountedPanes[1], paneId: 9, drivesTabTitle: true }, + { ...mountedPanes[0], paneId: 8, drivesTabTitle: false } + ] + + expect(createPlan({ panes: mountedPanes }).materialKey).toBe( + createPlan({ panes: recapturedPanes }).materialKey + ) + }) + + it('includes generation and exact leaf-to-PTY topology', () => { + const panes = [ + { leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID }, + { leafId: SECOND_LEAF_ID, ptyId: SECOND_PTY_ID } + ] + const original = createPlan({ panes }) + const reminted = createPlan({ panes, generation: 2 }) + const swapped = createPlan({ + panes: [ + { leafId: FIRST_LEAF_ID, ptyId: SECOND_PTY_ID }, + { leafId: SECOND_LEAF_ID, ptyId: FIRST_PTY_ID } + ] + }) + + expect(reminted.materialKey).not.toBe(original.materialKey) + expect(swapped.materialKey).not.toBe(original.materialKey) + }) + + it('distinguishes pending, unsupported, and authoritative local providers', () => { + const pending = createPlan({ capability: 'pending' }) + const blocked = createPlan({ capability: 'unavailable' }) + const covered = createPlan({ capability: 'authoritative' }) + + expect(pending).toMatchObject({ + status: 'pending', + issue: { reason: 'provider-capability-pending' } + }) + expect(blocked).toMatchObject({ + status: 'blocked', + issue: { reason: 'provider-snapshot-unavailable' } + }) + expect(covered.status).toBe('covered') + expect(new Set([pending.materialKey, blocked.materialKey, covered.materialKey]).size).toBe(3) + }) + + it('keeps missing models pending and malformed topology blocked', () => { + expect(createPlan({ panes: [] })).toMatchObject({ + status: 'pending', + issue: { reason: 'pane-model-pending' } + }) + expect(createPlan({ panes: [{ leafId: FIRST_LEAF_ID, ptyId: null }] })).toMatchObject({ + status: 'pending', + issue: { reason: 'pane-pty-pending' } + }) + expect(createPlan({ panes: [{ leafId: 'legacy-leaf', ptyId: FIRST_PTY_ID }] })).toMatchObject({ + status: 'blocked', + issue: { reason: 'invalid-leaf-id' } + }) + }) + + it('keys SSH and paired-runtime coverage to their exact authority', () => { + const sshPanes = [{ leafId: FIRST_LEAF_ID, ptyId: 'ssh:connection-1@@pty-1' }] + const pairedPanes = [{ leafId: FIRST_LEAF_ID, ptyId: 'remote:env-1@@terminal-1' }] + + expect(createPlan({ panes: sshPanes, sshParkingEnabled: false }).status).toBe('blocked') + expect(createPlan({ panes: sshPanes, sshParkingEnabled: true }).status).toBe('covered') + expect(createPlan({ panes: pairedPanes }).status).toBe('blocked') + expect(createPlan({ panes: pairedPanes, pairedEnvironments: ['env-1'] }).status).toBe('covered') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-coverage-plan.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-coverage-plan.ts new file mode 100644 index 00000000000..928cbd79c65 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-coverage-plan.ts @@ -0,0 +1,222 @@ +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import type { TerminalProviderSnapshotCapabilityState } from '../terminal/terminal-provider-snapshot-capability' +import { + isSnapshotBackedTerminalPty, + type TerminalParkRestorePolicy +} from './terminal-hidden-view-parking' +import { + normalizeParkedTerminalPaneMaterialBindings, + type ParkableTerminalTabModel, + type ParkedTerminalPaneMaterialBinding +} from './terminal-parked-watcher-reconciliation' + +export type TerminalParkedWatcherPlanGeneration = number | null +export type TerminalParkedWatcherCoverageIssueReason = + | 'pane-model-pending' + | 'pane-pty-pending' + | 'invalid-leaf-id' + | 'duplicate-leaf-binding' + | 'duplicate-pty-binding' + | 'pty-not-restorable' + | 'ssh-parking-disabled' + | 'paired-runtime-unavailable' + | 'provider-capability-pending' + | 'provider-snapshot-unavailable' + | 'pty-ineligible' + +export type TerminalParkedWatcherCoverageIssue = { + reason: TerminalParkedWatcherCoverageIssueReason + leafId?: string + ptyId?: string | null +} + +type TerminalParkedWatcherCoveragePlanBase = { + materialKey: string + worktreeId: string + tabId: string + tabPtyId: string | null + generation: TerminalParkedWatcherPlanGeneration + panes: readonly ParkedTerminalPaneMaterialBinding[] +} + +export type TerminalParkedWatcherPendingPlan = TerminalParkedWatcherCoveragePlanBase & { + status: 'pending' + issue: TerminalParkedWatcherCoverageIssue +} + +export type TerminalParkedWatcherBlockedPlan = TerminalParkedWatcherCoveragePlanBase & { + status: 'blocked' + issue: TerminalParkedWatcherCoverageIssue +} + +export type TerminalParkedWatcherCoveredPlan = TerminalParkedWatcherCoveragePlanBase & { + status: 'covered' +} + +export type TerminalParkedWatcherCoveragePlan = + | TerminalParkedWatcherPendingPlan + | TerminalParkedWatcherBlockedPlan + | TerminalParkedWatcherCoveredPlan + +export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean + +type TerminalParkedWatcherPtyCoverage = + | { status: 'covered'; authority: string } + | { status: 'pending'; reason: TerminalParkedWatcherCoverageIssueReason } + | { status: 'blocked'; reason: TerminalParkedWatcherCoverageIssueReason } + +type EvaluatedPane = { + pane: ParkedTerminalPaneMaterialBinding + coverage: TerminalParkedWatcherPtyCoverage +} + +function resolveTerminalParkedWatcherPtyCoverage(args: { + ptyId: string + worktreeId: string + restorePolicy: TerminalParkRestorePolicy + providerSnapshotCapability: (ptyId: string) => TerminalProviderSnapshotCapabilityState + isPtyEligible?: ParkedTerminalPtyEligibility +}): TerminalParkedWatcherPtyCoverage { + const ssh = parseAppSshPtyId(args.ptyId) + let coverage: TerminalParkedWatcherPtyCoverage + if (ssh) { + coverage = + args.restorePolicy.sshParkingEnabled === true + ? { status: 'covered', authority: `ssh:${ssh.connectionId}` } + : { status: 'blocked', reason: 'ssh-parking-disabled' } + } else if (isRemoteRuntimePtyId(args.ptyId)) { + const environmentId = getRemoteRuntimePtyEnvironmentId(args.ptyId) + coverage = + environmentId !== null && + args.restorePolicy.pairedRuntimeParkingEnvironmentIds?.has(environmentId) === true + ? { status: 'covered', authority: `paired-runtime:${environmentId}` } + : { status: 'blocked', reason: 'paired-runtime-unavailable' } + } else if (!isSnapshotBackedTerminalPty(args.ptyId, args.worktreeId)) { + coverage = { status: 'blocked', reason: 'pty-not-restorable' } + } else { + const capability = args.providerSnapshotCapability(args.ptyId) + coverage = + capability === 'pending' + ? { status: 'pending', reason: 'provider-capability-pending' } + : capability === 'authoritative' + ? { status: 'covered', authority: 'local-provider-snapshot' } + : { status: 'blocked', reason: 'provider-snapshot-unavailable' } + } + if (coverage.status === 'covered' && args.isPtyEligible?.(args.ptyId) === false) { + return { status: 'blocked', reason: 'pty-ineligible' } + } + return coverage +} + +function evaluatePane(args: { + pane: ParkedTerminalPaneMaterialBinding + leafCounts: ReadonlyMap + ptyCounts: ReadonlyMap + worktreeId: string + restorePolicy: TerminalParkRestorePolicy + providerSnapshotCapability: (ptyId: string) => TerminalProviderSnapshotCapabilityState + isPtyEligible?: ParkedTerminalPtyEligibility +}): EvaluatedPane { + const { pane } = args + if (!isTerminalLeafId(pane.leafId)) { + return { pane, coverage: { status: 'blocked', reason: 'invalid-leaf-id' } } + } + if ((args.leafCounts.get(pane.leafId) ?? 0) > 1) { + return { pane, coverage: { status: 'blocked', reason: 'duplicate-leaf-binding' } } + } + if (pane.ptyId === null) { + return { pane, coverage: { status: 'pending', reason: 'pane-pty-pending' } } + } + if ((args.ptyCounts.get(pane.ptyId) ?? 0) > 1) { + return { pane, coverage: { status: 'blocked', reason: 'duplicate-pty-binding' } } + } + return { + pane, + coverage: resolveTerminalParkedWatcherPtyCoverage({ + ptyId: pane.ptyId, + worktreeId: args.worktreeId, + restorePolicy: args.restorePolicy, + providerSnapshotCapability: args.providerSnapshotCapability, + ...(args.isPtyEligible ? { isPtyEligible: args.isPtyEligible } : {}) + }) + } +} + +function coverageMaterialRow({ pane, coverage }: EvaluatedPane): readonly unknown[] { + return [ + pane.leafId, + pane.ptyId, + coverage.status, + coverage.status === 'covered' ? coverage.authority : coverage.reason + ] +} + +export function createTerminalParkedWatcherCoveragePlan(args: { + worktreeId: string + tab: ParkableTerminalTabModel + panes: readonly ParkedTerminalPaneMaterialBinding[] + restorePolicy: TerminalParkRestorePolicy + providerSnapshotCapability: (ptyId: string) => TerminalProviderSnapshotCapabilityState + generation?: TerminalParkedWatcherPlanGeneration + isPtyEligible?: ParkedTerminalPtyEligibility +}): TerminalParkedWatcherCoveragePlan { + const panes = normalizeParkedTerminalPaneMaterialBindings(args.panes) + const leafCounts = new Map() + const ptyCounts = new Map() + for (const pane of panes) { + leafCounts.set(pane.leafId, (leafCounts.get(pane.leafId) ?? 0) + 1) + if (pane.ptyId !== null) { + ptyCounts.set(pane.ptyId, (ptyCounts.get(pane.ptyId) ?? 0) + 1) + } + } + const evaluated = panes.map((pane) => + evaluatePane({ + pane, + leafCounts, + ptyCounts, + worktreeId: args.worktreeId, + restorePolicy: args.restorePolicy, + providerSnapshotCapability: args.providerSnapshotCapability, + ...(args.isPtyEligible ? { isPtyEligible: args.isPtyEligible } : {}) + }) + ) + const generation = args.generation ?? args.tab.generation ?? null + const base: TerminalParkedWatcherCoveragePlanBase = { + materialKey: JSON.stringify([ + 'terminal-parked-watcher-plan-v1', + args.worktreeId, + args.tab.id, + args.tab.ptyId, + generation, + evaluated.map(coverageMaterialRow) + ]), + worktreeId: args.worktreeId, + tabId: args.tab.id, + tabPtyId: args.tab.ptyId, + generation, + panes + } + if (panes.length === 0) { + return { ...base, status: 'pending', issue: { reason: 'pane-model-pending' } } + } + const blocked = evaluated.find((entry) => entry.coverage.status === 'blocked') + if (blocked?.coverage.status === 'blocked') { + return { + ...base, + status: 'blocked', + issue: { ...blocked.pane, reason: blocked.coverage.reason } + } + } + const pending = evaluated.find((entry) => entry.coverage.status === 'pending') + if (pending?.coverage.status === 'pending') { + return { + ...base, + status: 'pending', + issue: { ...pending.pane, reason: pending.coverage.reason } + } + } + return { ...base, status: 'covered' } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-host-transition.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-host-transition.test.ts new file mode 100644 index 00000000000..43a41d77e0c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-host-transition.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalParkedWatcherSyncAcknowledgement } from './terminal-park-episode-lease' + +const WORKTREE_ID = 'repo::/worktree' +const TAB_ID = 'tab-1' +const PTY_ID = `${WORKTREE_ID}@@session-1` +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +type ExitCallback = (code: number, context: { hadPrimary: boolean }) => void + +const transport = vi.hoisted(() => ({ + dataHandlers: new Map void>(), + dataSidecars: new Map void>>(), + exitSidecars: new Map>(), + setHiddenRendererPty: vi.fn(), + setPtyDeliveryInterest: vi.fn(), + closeTerminalTab: vi.fn(), + discardPreHandlerPtyState: vi.fn(), + clearPreHandlerPtyData: vi.fn(), + drainPreHandlerPtyData: vi.fn(), + dispatchNotification: vi.fn() +})) + +vi.mock('./pty-dispatcher', () => ({ + ensurePtyDispatcher: vi.fn(), + ptyDataHandlers: transport.dataHandlers, + ptyDataSidecars: transport.dataSidecars, + subscribeToPtyExit: (ptyId: string, callback: ExitCallback) => { + const callbacks = transport.exitSidecars.get(ptyId) ?? new Set() + callbacks.add(callback) + transport.exitSidecars.set(ptyId, callbacks) + return () => { + const current = transport.exitSidecars.get(ptyId) + current?.delete(callback) + if (current?.size === 0) { + transport.exitSidecars.delete(ptyId) + } + } + } +})) + +vi.mock('./pty-pre-handler-buffer', () => ({ + discardPreHandlerPtyState: transport.discardPreHandlerPtyState, + clearPreHandlerPtyData: transport.clearPreHandlerPtyData, + drainPreHandlerPtyData: transport.drainPreHandlerPtyData +})) + +vi.mock('../terminal/terminal-tab-actions', () => ({ + closeTerminalTab: transport.closeTerminalTab +})) + +vi.mock('./use-notification-dispatch', () => ({ + dispatchTerminalNotification: transport.dispatchNotification +})) + +vi.mock('./parked-terminal-command-status', () => ({ + createParkedTerminalCommandStatusPolicy: () => ({ + onCommandFinished: vi.fn(), + onCommandCodeWorking: vi.fn(), + onCommandCodeDone: vi.fn(), + dispose: vi.fn() + }), + readInFlightCommandCodeTurn: () => null +})) + +vi.mock('@/lib/terminal-theme', () => ({ getSystemPrefersDark: () => true })) +vi.mock('./terminal-freeze-breadcrumbs', () => ({ recordTerminalFreezeBreadcrumb: vi.fn() })) + +type MockStoreState = { + tabsByWorktree: Record + terminalLayoutsByTabId: Record + runtimePaneTitlesByTabId: Record> + settings: { + terminalMainSideEffectAuthority: boolean + terminalHiddenDeliveryGate: boolean + terminalSshViewParking: boolean + notifications: { enabled: boolean } + } + runtimeStatusByEnvironmentId: Map + agentStatusByPaneKey: Record + clearRuntimePaneTitle: ReturnType + setRuntimePaneTitle: ReturnType + updateTabTitle: ReturnType + markWorktreeUnread: ReturnType + markTerminalTabUnread: ReturnType + markTerminalPaneUnread: ReturnType + setCacheTimerStartedAt: ReturnType + observeTerminalGitHubPullRequestLink: ReturnType + clearTabLaunchAgent: ReturnType + setTabLayout: ReturnType +} + +let storeState: MockStoreState + +vi.mock('@/store', () => ({ useAppStore: { getState: () => storeState } })) + +import { + clearTerminalProviderSnapshotCapabilities, + synchronizeTerminalProviderSnapshotCapabilities +} from '../terminal/terminal-provider-snapshot-capability' +import { + captureParkedTerminalPaneCandidates, + getParkedTerminalWatcherTabIds, + planParkedTerminalTabWatcherCoverage, + pruneParkedTerminalWatchers, + syncParkedTerminalTabWatchersWithAcknowledgements +} from './terminal-parked-tab-watchers' +import { parkedWatchersByTabId } from './terminal-parked-watcher-registry' +import { + _dispatchTerminalSideEffectBatchForTest, + _resetTerminalSideEffectFactConsumersForTest +} from './terminal-side-effect-facts-handler' +import { _resetPtyRendererDeliveryClaimsForTest } from './pty-renderer-delivery-claims' + +type Host = 'split' | 'legacy' +type WatcherMode = 'bytes' | 'hidden-facts' + +function createStoreState(mode: WatcherMode): MockStoreState { + return { + tabsByWorktree: { + [WORKTREE_ID]: [{ id: TAB_ID, ptyId: PTY_ID, generation: 1 }] + }, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + settings: { + terminalMainSideEffectAuthority: mode === 'hidden-facts', + terminalHiddenDeliveryGate: true, + terminalSshViewParking: true, + notifications: { enabled: false } + }, + runtimeStatusByEnvironmentId: new Map(), + agentStatusByPaneKey: {}, + clearRuntimePaneTitle: vi.fn(), + setRuntimePaneTitle: vi.fn(), + updateTabTitle: vi.fn(), + markWorktreeUnread: vi.fn(), + markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn(), + setCacheTimerStartedAt: vi.fn(), + observeTerminalGitHubPullRequestLink: vi.fn(), + clearTabLaunchAgent: vi.fn(), + setTabLayout: vi.fn() + } +} + +function syncFromHost(_host: Host): TerminalParkedWatcherSyncAcknowledgement { + const tab = storeState.tabsByWorktree[WORKTREE_ID][0] + const plan = planParkedTerminalTabWatcherCoverage(WORKTREE_ID, tab) + const [acknowledgement] = syncParkedTerminalTabWatchersWithAcknowledgements({ + worktreeId: WORKTREE_ID, + tabs: [tab], + parkedTabIds: new Set([TAB_ID]), + coveragePlansByTabId: new Map([[TAB_ID, plan]]), + restoreTitleOnStartTabIds: new Set([TAB_ID]) + }) + return acknowledgement +} + +function emitData(data: string): void { + for (const callback of Array.from(transport.dataSidecars.get(PTY_ID) ?? [])) { + callback(data) + } +} + +function emitExit(): void { + const callbacks = Array.from(transport.exitSidecars.get(PTY_ID) ?? []) + transport.exitSidecars.delete(PTY_ID) + for (const callback of callbacks) { + callback(0, { hadPrimary: false }) + } +} + +describe('parked watcher split and legacy host transitions', () => { + const originalWindow = (globalThis as { window?: Window }).window + + beforeEach(async () => { + vi.useFakeTimers() + ;(globalThis as { window: Window }).window = { + ...originalWindow, + api: { + pty: { + setHiddenRendererPty: transport.setHiddenRendererPty, + setPtyDeliveryInterest: transport.setPtyDeliveryInterest, + onSideEffect: vi.fn(() => () => {}), + write: vi.fn() + } + } + } as unknown as Window + clearTerminalProviderSnapshotCapabilities() + await synchronizeTerminalProviderSnapshotCapabilities([PTY_ID], async () => [ + { id: PTY_ID, authoritative: true } + ]) + }) + + afterEach(() => { + pruneParkedTerminalWatchers(new Set()) + _resetTerminalSideEffectFactConsumersForTest() + _resetPtyRendererDeliveryClaimsForTest() + clearTerminalProviderSnapshotCapabilities() + transport.dataHandlers.clear() + transport.dataSidecars.clear() + transport.exitSidecars.clear() + vi.clearAllMocks() + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: Window }).window = originalWindow + } else { + delete (globalThis as { window?: Window }).window + } + }) + + it.each([ + ['split', 'legacy', 'bytes'], + ['legacy', 'split', 'bytes'], + ['split', 'legacy', 'hidden-facts'], + ['legacy', 'split', 'hidden-facts'] + ] as const)('keeps one owner across %s to %s in %s mode', async (first, second, mode) => { + storeState = createStoreState(mode) + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 1, [ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true } + ]) + + const firstAcknowledgement = syncFromHost(first) + const secondAcknowledgement = syncFromHost(second) + + expect(firstAcknowledgement).toMatchObject({ + status: 'covering', + tabId: TAB_ID, + watchedPtyIds: [PTY_ID] + }) + expect(secondAcknowledgement).toEqual(firstAcknowledgement) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + expect(parkedWatchersByTabId.get(TAB_ID)?.disposersByPtyId.size).toBe(1) + expect(transport.exitSidecars.get(PTY_ID)?.size).toBe(1) + + if (mode === 'bytes') { + expect(transport.dataSidecars.get(PTY_ID)?.size).toBe(2) + expect(transport.setPtyDeliveryInterest.mock.calls).toEqual([[PTY_ID, true]]) + expect(transport.setHiddenRendererPty).not.toHaveBeenCalled() + emitData('\x07') + vi.advanceTimersByTime(0) + } else { + expect(transport.dataSidecars.get(PTY_ID)).toBeUndefined() + expect(transport.setPtyDeliveryInterest).not.toHaveBeenCalled() + expect(transport.setHiddenRendererPty.mock.calls).toEqual([[PTY_ID, true]]) + _dispatchTerminalSideEffectBatchForTest({ + ptyId: PTY_ID, + seq: 1, + facts: [{ kind: 'bell' }] + }) + } + expect(storeState.markWorktreeUnread).toHaveBeenCalledOnce() + expect(storeState.markTerminalTabUnread).toHaveBeenCalledOnce() + + emitExit() + + expect(transport.closeTerminalTab).toHaveBeenCalledOnce() + expect(transport.exitSidecars.get(PTY_ID)).toBeUndefined() + if (mode === 'bytes') { + expect(transport.setPtyDeliveryInterest.mock.calls).toEqual([ + [PTY_ID, true], + [PTY_ID, false] + ]) + } else { + expect(transport.setHiddenRendererPty.mock.calls).toEqual([ + [PTY_ID, true], + [PTY_ID, false] + ]) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts index d5fd24ecae3..66662328a41 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts @@ -77,7 +77,7 @@ afterEach(() => { }) it('retains a continuing watcher and title while reconciling a reminted split leaf', () => { - captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [ + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, null, [ { ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }, { ptyId: OLD_SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } ]) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts index 18e8f41cac6..3fe982173ff 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts @@ -4,11 +4,14 @@ import { retireParkedTerminalTab } from './terminal-parked-watcher-registry' import { + createParkedTerminalWatcherTopologyKey, + normalizeParkedTerminalPaneMaterialBindings, reconcileParkedWatcherPtyIds, resolveParkedTerminalPaneCandidates } from './terminal-parked-watcher-reconciliation' const TAB_ID = 'tab-1' +const OTHER_TAB_ID = 'tab-2' const WORKTREE_ID = 'repo::/worktree' const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111' const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' @@ -16,13 +19,159 @@ const FIRST_PTY_ID = 'remote:env-1@@terminal-1' const OLD_SECOND_PTY_ID = 'remote:env-1@@terminal-2' const NEW_SECOND_PTY_ID = 'remote:env-1@@terminal-3' +type TopologyState = Parameters[2] + +function singlePaneTopologyState(args?: { + firstTitleSlot?: number + firstTitle?: string +}): TopologyState { + return { + runtimePaneTitlesByTabId: { + [TAB_ID]: { [args?.firstTitleSlot ?? 1]: args?.firstTitle ?? 'first title' }, + [OTHER_TAB_ID]: { 2: 'second title' } + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: FIRST_LEAF_ID }, + activeLeafId: FIRST_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [FIRST_LEAF_ID]: FIRST_PTY_ID } + }, + [OTHER_TAB_ID]: { + root: { type: 'leaf', leafId: SECOND_LEAF_ID }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SECOND_LEAF_ID]: OLD_SECOND_PTY_ID } + } + } + } +} + +function splitPaneTopologyState(secondPtyId: string): TopologyState { + return { + runtimePaneTitlesByTabId: {}, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: FIRST_LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: FIRST_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [FIRST_LEAF_ID]: FIRST_PTY_ID, + [SECOND_LEAF_ID]: secondPtyId + } + } + } + } +} + afterEach(() => { retireParkedTerminalTab(TAB_ID) + retireParkedTerminalTab(OTHER_TAB_ID) }) describe('paired parked-watcher reconciliation', () => { + it('normalizes only mount-independent leaf and PTY bindings', () => { + const captures = [ + { + leafId: SECOND_LEAF_ID, + ptyId: OLD_SECOND_PTY_ID, + paneId: 7, + drivesTabTitle: true + }, + { leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 4, drivesTabTitle: false } + ] + expect(normalizeParkedTerminalPaneMaterialBindings(captures)).toEqual([ + { leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID }, + { leafId: SECOND_LEAF_ID, ptyId: OLD_SECOND_PTY_ID } + ]) + }) + + it('keeps the topology key stable across tab order', () => { + const tabs = [ + { id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 }, + { id: OTHER_TAB_ID, ptyId: OLD_SECOND_PTY_ID, generation: 2 } + ] + const state = singlePaneTopologyState() + + expect(createParkedTerminalWatcherTopologyKey(WORKTREE_ID, tabs, state)).toBe( + createParkedTerminalWatcherTopologyKey(WORKTREE_ID, tabs.toReversed(), state) + ) + }) + + it('ignores pane metadata, runtime titles, and capture source', () => { + const tabs = [{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 }] + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 1, [ + { + leafId: FIRST_LEAF_ID, + ptyId: FIRST_PTY_ID, + paneId: 99, + drivesTabTitle: false + } + ]) + const capturedKey = createParkedTerminalWatcherTopologyKey( + WORKTREE_ID, + tabs, + singlePaneTopologyState({ firstTitleSlot: 3, firstTitle: 'old title' }) + ) + + retireParkedTerminalTab(TAB_ID) + const fallbackKey = createParkedTerminalWatcherTopologyKey( + WORKTREE_ID, + tabs, + singlePaneTopologyState({ firstTitleSlot: 17, firstTitle: 'new title' }) + ) + + expect(fallbackKey).toBe(capturedKey) + }) + + it('changes the topology key when a tab generation changes', () => { + const state = singlePaneTopologyState() + const original = createParkedTerminalWatcherTopologyKey( + WORKTREE_ID, + [{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 }], + state + ) + const reminted = createParkedTerminalWatcherTopologyKey( + WORKTREE_ID, + [{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 2 }], + state + ) + + expect(reminted).not.toBe(original) + }) + + it('changes the topology key for a layout-only leaf-to-PTY remint', () => { + const tabs = [{ id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 1 }] + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 1, [ + { leafId: FIRST_LEAF_ID, ptyId: FIRST_PTY_ID, paneId: 1, drivesTabTitle: true }, + { + leafId: SECOND_LEAF_ID, + ptyId: OLD_SECOND_PTY_ID, + paneId: 2, + drivesTabTitle: false + } + ]) + const original = createParkedTerminalWatcherTopologyKey( + WORKTREE_ID, + tabs, + splitPaneTopologyState(OLD_SECOND_PTY_ID) + ) + const reminted = createParkedTerminalWatcherTopologyKey( + WORKTREE_ID, + tabs, + splitPaneTopologyState(NEW_SECOND_PTY_ID) + ) + + expect(reminted).not.toBe(original) + }) + it('prefers an authoritative inactive split-leaf remint over the unmount capture', () => { - captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [ + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, null, [ { ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }, { ptyId: OLD_SECOND_PTY_ID, @@ -33,6 +182,7 @@ describe('paired parked-watcher reconciliation', () => { ]) const panes = resolveParkedTerminalPaneCandidates( + WORKTREE_ID, { id: TAB_ID, ptyId: FIRST_PTY_ID }, { runtimePaneTitlesByTabId: {}, @@ -66,6 +216,94 @@ describe('paired parked-watcher reconciliation', () => { ]) }) + it('takes split title authority from the current layout instead of the capture', () => { + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, null, [ + { ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }, + { + ptyId: OLD_SECOND_PTY_ID, + paneId: 2, + leafId: SECOND_LEAF_ID, + drivesTabTitle: false + } + ]) + + const panes = resolveParkedTerminalPaneCandidates( + WORKTREE_ID, + { id: TAB_ID, ptyId: FIRST_PTY_ID }, + { + runtimePaneTitlesByTabId: {}, + terminalLayoutsByTabId: { + [TAB_ID]: { + ...splitPaneTopologyState(OLD_SECOND_PTY_ID).terminalLayoutsByTabId[TAB_ID], + activeLeafId: SECOND_LEAF_ID + } + } + } + ) + + expect(panes.map(({ leafId, drivesTabTitle }) => ({ leafId, drivesTabTitle }))).toEqual([ + { leafId: FIRST_LEAF_ID, drivesTabTitle: false }, + { leafId: SECOND_LEAF_ID, drivesTabTitle: true } + ]) + }) + + it('accepts only a capture from the requested worktree and generation', () => { + const capturedPanes = [ + { ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true } + ] + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 4, capturedPanes) + + expect( + resolveParkedTerminalPaneCandidates( + WORKTREE_ID, + { id: TAB_ID, ptyId: null, generation: 4 }, + { runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {} } + ) + ).toEqual(capturedPanes) + }) + + it('rejects a capture from another worktree when no fallback topology exists', () => { + captureParkedTerminalPaneCandidates(TAB_ID, 'repo::/old-worktree', 4, [ + { ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true } + ]) + + expect( + resolveParkedTerminalPaneCandidates( + WORKTREE_ID, + { id: TAB_ID, ptyId: null, generation: 4 }, + { runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {} } + ) + ).toEqual([]) + }) + + it('rejects a same-PTY capture from a previous tab generation', () => { + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 3, [ + { ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true } + ]) + + expect( + resolveParkedTerminalPaneCandidates( + WORKTREE_ID, + { id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 4 }, + { runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {} } + ) + ).toEqual([]) + }) + + it('does not reuse pane metadata from a stale capture', () => { + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, 3, [ + { ptyId: FIRST_PTY_ID, paneId: 99, leafId: FIRST_LEAF_ID, drivesTabTitle: false } + ]) + + expect( + resolveParkedTerminalPaneCandidates( + WORKTREE_ID, + { id: TAB_ID, ptyId: FIRST_PTY_ID, generation: 4 }, + singlePaneTopologyState() + ) + ).toEqual([{ ptyId: FIRST_PTY_ID, paneId: 1, leafId: FIRST_LEAF_ID, drivesTabTitle: true }]) + }) + it('surgically reconciles a reminted split leaf without restarting its sibling', () => { expect( reconcileParkedWatcherPtyIds({ diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts index 5376cb9ed13..d22961ebeb3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.ts @@ -1,21 +1,35 @@ import type { TerminalTab } from '../../../../shared/types' -import type { useAppStore } from '@/store' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import { useAppStore } from '@/store' import { collectLeafIdsInOrder, resolveRootlessTerminalLayoutLeafId } from './terminal-layout-leaf-ids' import { capturedPanesByTabId, + disposeParkedTabWatchers, + parkedWatchersByTabId, + type ParkedTabWatcherEntry, type ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' +import { + isParkRestorableTerminalPty, + selectPairedRuntimeParkingEnvironmentIds, + type TerminalParkRestorePolicy +} from './terminal-hidden-view-parking' +import { startParkedPtyWatcher } from './terminal-parked-pty-watcher' -export type ParkableTerminalTabModel = Pick +export type ParkableTerminalTabModel = Pick +export type ParkedTerminalPaneMaterialBinding = Pick type ParkedPaneFallbackState = { terminalLayoutsByTabId: ReturnType['terminalLayoutsByTabId'] runtimePaneTitlesByTabId: ReturnType['runtimePaneTitlesByTabId'] } +type ParkedPaneTopologyState = Pick & + Partial> + export function fallbackParkedPaneCandidates( tab: ParkableTerminalTabModel, state: ParkedPaneFallbackState @@ -41,13 +55,18 @@ export function fallbackParkedPaneCandidates( } export function resolveParkedTerminalPaneCandidates( + worktreeId: string, tab: ParkableTerminalTabModel, state: ParkedPaneFallbackState ): ParkedTerminalPaneCapture[] { const captured = capturedPanesByTabId.get(tab.id) const fallback = fallbackParkedPaneCandidates(tab, state) - const capturedIsCurrent = + const capturedHasCurrentIdentity = captured !== undefined && + captured.worktreeId === worktreeId && + captured.generation === (tab.generation ?? null) + const capturedIsCurrent = + capturedHasCurrentIdentity && captured.panes.length > 0 && (tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId)) && (fallback.length === 0 || @@ -58,14 +77,67 @@ export function resolveParkedTerminalPaneCandidates( ) ))) if (capturedIsCurrent) { - return captured.panes + return captured.panes.map((pane) => { + const current = fallback.find( + (candidate) => candidate.leafId === pane.leafId && candidate.ptyId === pane.ptyId + ) + return current ? { ...pane, drivesTabTitle: current.drivesTabTitle } : pane + }) } return fallback.map((pane) => { - const prior = captured?.panes.find((candidate) => candidate.leafId === pane.leafId) - return prior ? { ...pane, paneId: prior.paneId, drivesTabTitle: prior.drivesTabTitle } : pane + const prior = capturedHasCurrentIdentity + ? captured.panes.find((candidate) => candidate.leafId === pane.leafId) + : undefined + return prior ? { ...pane, paneId: prior.paneId } : pane }) } +export function normalizeParkedTerminalPaneMaterialBindings( + panes: readonly ParkedTerminalPaneMaterialBinding[] +): ParkedTerminalPaneMaterialBinding[] { + return panes + .map(({ leafId, ptyId }) => ({ leafId, ptyId })) + .sort( + (left, right) => + left.leafId.localeCompare(right.leafId) || + (left.ptyId ?? '').localeCompare(right.ptyId ?? '') + ) +} + +export function resolveParkedTerminalPaneMaterialBindings( + worktreeId: string, + tab: ParkableTerminalTabModel, + state: ParkedPaneFallbackState +): ParkedTerminalPaneMaterialBinding[] { + return normalizeParkedTerminalPaneMaterialBindings( + resolveParkedTerminalPaneCandidates(worktreeId, tab, state) + ) +} + +export function createParkedTerminalWatcherTopologyKey( + worktreeId: string, + tabs: readonly ParkableTerminalTabModel[], + state: ParkedPaneTopologyState +): string { + const topologyState: ParkedPaneFallbackState = { + terminalLayoutsByTabId: state.terminalLayoutsByTabId, + // Runtime titles affect only watcher pane slots, never material leaf-to-PTY topology. + runtimePaneTitlesByTabId: {} + } + return JSON.stringify([ + 'terminal-parked-watcher-topology-v1', + worktreeId, + [...tabs] + .sort((left, right) => left.id.localeCompare(right.id)) + .map((tab) => [ + tab.id, + tab.ptyId, + tab.generation ?? null, + resolveParkedTerminalPaneMaterialBindings(worktreeId, tab, topologyState) + ]) + ]) +} + export function reconcileParkedWatcherPtyIds(args: { currentTabPtyId: string | null entryTabPtyId: string | null @@ -89,3 +161,117 @@ export function reconcileParkedWatcherPtyIds(args: { .map(([, paneId]) => paneId) } } + +export function parkedWatcherRestorePolicyFromState(state: { + settings: { terminalSshViewParking?: boolean } | null + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: { capabilities?: readonly string[] } | null | undefined } + > +}): TerminalParkRestorePolicy { + return { + sshParkingEnabled: state.settings?.terminalSshViewParking !== false, + pairedRuntimeParkingEnvironmentIds: selectPairedRuntimeParkingEnvironmentIds( + state.runtimeStatusByEnvironmentId + ) + } +} + +function watchablePanes( + worktreeId: string, + tab: ParkableTerminalTabModel +): Map { + const state = useAppStore.getState() + const restorePolicy = parkedWatcherRestorePolicyFromState(state) + return new Map( + resolveParkedTerminalPaneCandidates(worktreeId, tab, state).flatMap((pane) => + pane.ptyId && + isTerminalLeafId(pane.leafId) && + isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) + ? [[pane.ptyId, pane] as const] + : [] + ) + ) +} + +export function startParkedTabWatchers( + worktreeId: string, + tab: ParkableTerminalTabModel, + restoreTitleOnRegister: boolean +): void { + const entry: ParkedTabWatcherEntry = { + worktreeId, + tabPtyId: tab.ptyId, + paneIdByPtyId: new Map(), + disposersByPtyId: new Map() + } + parkedWatchersByTabId.set(tab.id, entry) + const restorePolicy = parkedWatcherRestorePolicyFromState(useAppStore.getState()) + for (const pane of resolveParkedTerminalPaneCandidates(worktreeId, tab, useAppStore.getState())) { + startParkedPtyWatcher({ + worktreeId, + tab, + pane, + entry, + restoreTitleOnRegister, + restorePolicy + }) + } +} + +export function reconcileParkedTabWatchers( + worktreeId: string, + tab: ParkableTerminalTabModel, + entry: ParkedTabWatcherEntry, + restoreTitleOnRegister: boolean +): void { + const state = useAppStore.getState() + const expectedPanes = watchablePanes(worktreeId, tab) + const expectedPtyIds = new Set(expectedPanes.keys()) + const reconciliation = reconcileParkedWatcherPtyIds({ + currentTabPtyId: tab.ptyId, + entryTabPtyId: entry.tabPtyId, + paneIdByPtyId: entry.paneIdByPtyId, + expectedPtyIds + }) + if (reconciliation.restartAll) { + const retainedTitles = reconciliation.retainedPtyIds.flatMap((ptyId) => { + const paneId = entry.paneIdByPtyId.get(ptyId) + const title = + paneId === undefined ? undefined : state.runtimePaneTitlesByTabId[tab.id]?.[paneId] + return paneId !== undefined && title !== undefined ? [{ paneId, title }] : [] + }) + for (const paneId of reconciliation.retiredPaneIds) { + state.clearRuntimePaneTitle(tab.id, paneId) + } + disposeParkedTabWatchers(tab.id) + for (const { paneId, title } of retainedTitles) { + useAppStore.getState().setRuntimePaneTitle(tab.id, paneId, title) + } + startParkedTabWatchers(worktreeId, tab, restoreTitleOnRegister) + return + } + for (const [ptyId, paneId] of Array.from(entry.paneIdByPtyId)) { + if (expectedPtyIds.has(ptyId)) { + continue + } + entry.paneIdByPtyId.delete(ptyId) + entry.disposersByPtyId.get(ptyId)?.() + entry.disposersByPtyId.delete(ptyId) + state.clearRuntimePaneTitle(tab.id, paneId) + } + const restorePolicy = parkedWatcherRestorePolicyFromState(useAppStore.getState()) + for (const ptyId of reconciliation.addedPtyIds) { + const pane = expectedPanes.get(ptyId) + if (pane) { + startParkedPtyWatcher({ + worktreeId, + tab, + pane, + entry, + restoreTitleOnRegister, + restorePolicy + }) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts index eb436c8e127..1a75071c511 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts @@ -75,9 +75,14 @@ describe('terminal parked watcher registry removal', () => { paneIdByPtyId: new Map([[FLOATING_PTY_ID, 1]]), disposersByPtyId: new Map([[FLOATING_PTY_ID, floatingDispose]]) }) - capturedPanesByTabId.set(TAB_ID, { worktreeId: 'removed-worktree', panes: [] }) + capturedPanesByTabId.set(TAB_ID, { + worktreeId: 'removed-worktree', + generation: null, + panes: [] + }) capturedPanesByTabId.set(FLOATING_TAB_ID, { worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + generation: null, panes: [] }) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index 1f4b522040a..6b581994ee0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -19,7 +19,11 @@ export type ParkedTerminalPaneCapture = { drivesTabTitle: boolean } -export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] } +export type CapturedTabPanes = { + worktreeId: string + generation: number | null + panes: ParkedTerminalPaneCapture[] +} export const capturedPanesByTabId = new Map() @@ -30,9 +34,10 @@ export const capturedPanesByTabId = new Map() export function captureParkedTerminalPaneCandidates( tabId: string, worktreeId: string, + generation: number | null | undefined, panes: ParkedTerminalPaneCapture[] ): void { - capturedPanesByTabId.set(tabId, { worktreeId, panes }) + capturedPanesByTabId.set(tabId, { worktreeId, generation: generation ?? null, panes }) } export type ParkedTabWatcherEntry = { @@ -48,6 +53,53 @@ export type ParkedTabWatcherEntry = { export const parkedWatchersByTabId = new Map() +export type ParkedTerminalWatcherOwnershipLossEvent = { + worktreeId: string + tabId: string + reason: 'close-cancelled' | 'primary-exit-unowned' +} + +type ParkedTerminalWatcherOwnershipLossListener = ( + event: ParkedTerminalWatcherOwnershipLossEvent +) => void + +const ownershipLossListeners = new Set() +const ownershipLossReportedEntries = new WeakSet() + +export function subscribeParkedTerminalWatcherOwnershipLoss( + listener: ParkedTerminalWatcherOwnershipLossListener +): () => void { + ownershipLossListeners.add(listener) + return () => ownershipLossListeners.delete(listener) +} + +export function markParkedTerminalWatcherOwnershipRestored(entry: ParkedTabWatcherEntry): void { + ownershipLossReportedEntries.delete(entry) +} + +export function notifyParkedTerminalWatcherOwnershipLoss( + tabId: string, + entry: ParkedTabWatcherEntry, + reason: ParkedTerminalWatcherOwnershipLossEvent['reason'] +): void { + if ( + parkedWatchersByTabId.get(tabId) !== entry || + entry.disposersByPtyId.size > 0 || + ownershipLossReportedEntries.has(entry) + ) { + return + } + ownershipLossReportedEntries.add(entry) + const event = { worktreeId: entry.worktreeId, tabId, reason } as const + for (const listener of Array.from(ownershipLossListeners)) { + try { + listener(event) + } catch { + // Why: one host listener must not block another host's recovery. + } + } +} + export function getParkedTerminalWatcherTabIds(): string[] { return Array.from(parkedWatchersByTabId.keys()) } diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts index e05137ffb64..ab4906917b1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -184,6 +184,26 @@ describe('registerTerminalSideEffectFactConsumer', () => { ]) }) + it('attempts every live fact before rethrowing the first callback error', () => { + const events: string[] = [] + const firstError = new Error('bell failed') + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onBell: () => { + events.push('bell') + throw firstError + }, + onAgentBecameWorking: () => events.push('working') + } + }) + + expect(() => + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }, { kind: 'agent-working' }])) + ).toThrow(firstError) + expect(events).toEqual(['bell', 'working']) + }) + it('routes command-finished and pr-link facts to the registered consumer', () => { const events: unknown[][] = [] registerTerminalSideEffectFactConsumer({ @@ -495,6 +515,41 @@ describe('registerTerminalSideEffectFactConsumer', () => { expect(later).toEqual([]) }) + it('removes a consumer whose handoff drain throws and buffers for its replacement', () => { + const dispose = registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: createCallbackRecorder().callbacks + }) + dispose() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { seq: 1 })) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'agent-working' }, { kind: 'agent-exited' }], { seq: 2 }) + ) + const firstError = new Error('buffered bell failed') + const failedEvents: string[] = [] + + expect(() => + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onBell: () => { + failedEvents.push('bell') + throw firstError + }, + onAgentBecameWorking: () => failedEvents.push('working'), + onAgentExited: () => failedEvents.push('exited') + } + }) + ).toThrow(firstError) + expect(failedEvents).toEqual(['bell', 'working', 'exited']) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { seq: 3 })) + expect(failedEvents).toEqual(['bell', 'working', 'exited']) + const replacement = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: replacement.callbacks }) + expect(replacement.events).toEqual([['bell']]) + }) + it('does not buffer replay batches across a handoff', () => { // Why: the next consumer requests its own snapshot; a held replay would // regress the title it just restored. diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 546af83987b..fe96e217698 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -101,6 +101,8 @@ type ConsumerEntry = { lastLiveTitleSeq: number | null } +type FactDeliveryFailure = { error: unknown } + const consumersByPtyId = new Map() let channelUnsubscribe: (() => void) | null = null @@ -152,24 +154,36 @@ function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: } } -function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void { +function applyBatchToConsumer( + entry: ConsumerEntry, + batch: TerminalSideEffectBatch +): FactDeliveryFailure | null { + let failure: FactDeliveryFailure | null = null + const apply = (callback: () => void): void => { + try { + callback() + } catch (error) { + failure ??= { error } + } + } if (batch.replay) { // Why: the no-attention-replay rule — (re)attach snapshots restore title // state only; historical bells/completions must never fire again. A replay // older (by output sequence) than the last live title fact is stale. if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) { - return + return null } for (const fact of batch.facts) { if (fact.kind === 'title') { - entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle) + apply(() => entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle)) } } - return + return failure } for (const fact of batch.facts) { - applyLiveFact(entry, fact, batch.seq) + apply(() => applyLiveFact(entry, fact, batch.seq)) } + return failure } // Why: a reveal remount unregisters the parked watcher synchronously, but the @@ -243,18 +257,21 @@ function bufferHandoffFactBatch(batch: TerminalSideEffectBatch): void { buffer.batches.push(batch) } -function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): void { +function drainHandoffFactBuffer(ptyId: string, entry: ConsumerEntry): FactDeliveryFailure | null { const buffer = handoffFactBuffersByPtyId.get(ptyId) if (!buffer) { - return + return null } deleteHandoffFactBuffer(ptyId) if (buffer.expiresAtMs <= Date.now()) { - return + return null } + let failure: FactDeliveryFailure | null = null for (const batch of buffer.batches) { - applyBatchToConsumer(entry, batch) + const batchFailure = applyBatchToConsumer(entry, batch) + failure ??= batchFailure } + return failure } export function dispatchTerminalSideEffectBatch(batch: TerminalSideEffectBatch): void { @@ -263,7 +280,10 @@ export function dispatchTerminalSideEffectBatch(batch: TerminalSideEffectBatch): bufferHandoffFactBatch(batch) return } - applyBatchToConsumer(entry, batch) + const failure = applyBatchToConsumer(entry, batch) + if (failure) { + throw failure.error + } } function ensureSideEffectChannelSubscription(): void { @@ -304,7 +324,14 @@ export function registerTerminalSideEffectFactConsumer( } consumersByPtyId.set(options.ptyId, entry) // Why before the snapshot request: draining live facts sets lastLiveTitleSeq, so the async title replay is correctly dropped as stale. - drainHandoffFactBuffer(options.ptyId, entry) + const failure = drainHandoffFactBuffer(options.ptyId, entry) + if (failure) { + if (consumersByPtyId.get(options.ptyId) === entry) { + consumersByPtyId.delete(options.ptyId) + openHandoffFactBuffer(options.ptyId) + } + throw failure.error + } if (options.restoreTitleOnRegister) { const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index bc230ecd419..d7389e16a58 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -222,6 +222,7 @@ async function formatTerminalUrlTooltip( type UseTerminalPaneLifecycleDeps = { tabId: string worktreeId: string + terminalGeneration?: number cwd?: string startup?: { command: string @@ -587,6 +588,7 @@ export function retireMountedTerminalPaneSurface(args: { export function useTerminalPaneLifecycle({ tabId, worktreeId, + terminalGeneration, cwd, startup, setupSplit, @@ -1745,6 +1747,7 @@ export function useTerminalPaneLifecycle({ captureParkedTerminalPaneCandidates( tabId, worktreeId, + terminalGeneration, manager.getPanes().map((capturedPane) => ({ ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null, paneId: capturedPane.id, @@ -1788,7 +1791,7 @@ export function useTerminalPaneLifecycle({ setTabCanExpandPane(tabId, false) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tabId, cwd]) + }, [tabId, cwd, terminalGeneration]) // Why: mobile wake fanout — pane self-selects by worktreeId and fires its own armed --resume while staying hidden (no reveal/focus change). useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-park-candidates.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-park-candidates.ts new file mode 100644 index 00000000000..342cc49847b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-park-candidates.ts @@ -0,0 +1,182 @@ +import { useEffect, useRef, useState } from 'react' +import type { TerminalTab } from '../../../../shared/types' +import type { ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal' +import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines' +import { + TERMINAL_TAB_COLD_PARK_DELAY_MS, + selectColdParkedTerminalTabs, + type TerminalTabColdParkCandidate +} from './terminal-hidden-view-parking' +import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides' + +export type TerminalOverlayTabAssignment = { + groupId: string + isActiveInGroup: boolean +} + +function haveSameTerminalTabIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + +export function useTerminalTabColdParkCandidates(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + assignments: ReadonlyMap + isWorktreeActive: boolean + shouldMeasureHiddenWorktree: boolean + activityTerminalPortals: readonly ActivityTerminalPortalTarget[] + pendingStartupByTabId: Readonly> + terminalParkingEnabled: boolean + terminalSshParkingEnabled: boolean + pairedRuntimeParkingEnvironmentIds: ReadonlySet +}): ReadonlySet { + const { + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + shouldMeasureHiddenWorktree, + activityTerminalPortals, + pendingStartupByTabId, + terminalParkingEnabled, + terminalSshParkingEnabled, + pairedRuntimeParkingEnvironmentIds + } = args + const hiddenSinceByTabIdRef = useRef(new Map()) + const wasMeasuringHiddenWorktreeRef = useRef(false) + const measureParkCooldownUntilRef = useRef(null) + const recheckTimersByTabIdRef = useRef(new Map()) + const [recheckRevision, setRecheckRevision] = useState(0) + const [candidateTabIds, setCandidateTabIds] = useState>(() => new Set()) + + useEffect(() => { + const timers = recheckTimersByTabIdRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + useEffect(() => { + const timers = recheckTimersByTabIdRef.current + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + + const nowMs = Date.now() + const overrides = getTerminalParkingPolicyOverrides() + const currentTabIds = new Set(terminalTabs.map((tab) => tab.id)) + const portalTabIds = new Set( + activityTerminalPortals + .filter((portal) => portal.worktreeId === worktreeId) + .map((portal) => portal.tabId) + ) + for (const tabId of Array.from(hiddenSinceByTabIdRef.current.keys())) { + if (!currentTabIds.has(tabId)) { + hiddenSinceByTabIdRef.current.delete(tabId) + } + } + + if (shouldMeasureHiddenWorktree) { + wasMeasuringHiddenWorktreeRef.current = true + } else { + if (wasMeasuringHiddenWorktreeRef.current) { + measureParkCooldownUntilRef.current = + nowMs + (overrides.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS) + } + wasMeasuringHiddenWorktreeRef.current = false + } + if (isWorktreeActive) { + measureParkCooldownUntilRef.current = null + } + + const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id) + if (isVisible || hasActivityTerminalPortal) { + hiddenSinceByTabIdRef.current.delete(terminalTab.id) + } else if ( + !shouldMeasureHiddenWorktree && + !hiddenSinceByTabIdRef.current.has(terminalTab.id) + ) { + hiddenSinceByTabIdRef.current.set(terminalTab.id, nowMs) + } + return { + id: terminalTab.id, + ptyId: terminalTab.ptyId, + pendingActivationSpawn: terminalTab.pendingActivationSpawn, + isVisible, + hasActivityTerminalPortal, + hiddenSinceMs: hiddenSinceByTabIdRef.current.get(terminalTab.id) ?? null + } + }) + + const nextCandidateTabIds = selectColdParkedTerminalTabs({ + worktreeId, + terminalTabs: candidates, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + parkCooldownUntilMs: measureParkCooldownUntilRef.current, + restorePolicy: { + sshParkingEnabled: terminalSshParkingEnabled, + pairedRuntimeParkingEnvironmentIds + }, + ...overrides + }) + setCandidateTabIds((current) => + haveSameTerminalTabIds(current, nextCandidateTabIds) ? current : nextCandidateTabIds + ) + + for (const candidate of candidates) { + if ( + candidate.isVisible || + candidate.hasActivityTerminalPortal || + nextCandidateTabIds.has(candidate.id) + ) { + continue + } + const delayMs = getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: terminalParkingEnabled, + hiddenSinceMs: candidate.hiddenSinceMs, + parkCooldownUntilMs: measureParkCooldownUntilRef.current, + nowMs, + ...overrides + }) + if (delayMs !== null && delayMs > 0) { + const tabId = candidate.id + const timer = window.setTimeout(() => { + timers.delete(tabId) + setRecheckRevision((revision) => revision + 1) + }, delayMs) + timers.set(tabId, timer) + } + } + }, [ + activityTerminalPortals, + assignments, + isWorktreeActive, + pairedRuntimeParkingEnvironmentIds, + pendingStartupByTabId, + recheckRevision, + shouldMeasureHiddenWorktree, + terminalParkingEnabled, + terminalSshParkingEnabled, + terminalTabs, + worktreeId + ]) + + return candidateTabIds +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index 888f82237ee..50266c7732a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -16,8 +16,10 @@ const mocks = vi.hoisted(() => ({ }, exemptTabIds: new Set(), exemptSelectCalls: 0, - /** Toggled to churn the park verdict the way the crash cluster does. */ - watcherCoverage: true + planRevision: 0, + failWatcherSync: false, + watcherSyncParkedTabIds: [] as string[][], + ownershipLossListener: null as null | ((event: { worktreeId: string; tabId: string }) => void) })) vi.mock('../../store', () => ({ @@ -41,9 +43,60 @@ vi.mock('./terminal-eviction-exempt-tabs', () => ({ })) vi.mock('./terminal-parked-tab-watchers', () => ({ - canWatcherCoverParkedTerminalTab: () => mocks.watcherCoverage, disposeParkedTerminalWatchersForWorktree: vi.fn(), - syncParkedTerminalTabWatchers: vi.fn() + planParkedTerminalTabWatcherCoverage: (worktreeId: string, tab: TerminalTab) => ({ + status: 'covered', + materialKey: `${worktreeId}:${tab.id}:${tab.ptyId}:${mocks.planRevision}`, + worktreeId, + tabId: tab.id, + tabPtyId: tab.ptyId, + generation: tab.generation ?? null, + panes: [ + { + leafId: '11111111-1111-4111-8111-111111111111', + ptyId: tab.ptyId + } + ] + }), + subscribeParkedTerminalWatcherOwnershipLoss: ( + listener: (event: { worktreeId: string; tabId: string }) => void + ) => { + mocks.ownershipLossListener = listener + return () => { + if (mocks.ownershipLossListener === listener) { + mocks.ownershipLossListener = null + } + } + }, + syncParkedTerminalTabWatchersWithAcknowledgements: (args: { + parkedTabIds: ReadonlySet + forcedTabIds: ReadonlySet + coveragePlansByTabId: ReadonlyMap< + string, + { materialKey: string; panes: readonly { ptyId: string | null }[] } + > + }) => { + mocks.watcherSyncParkedTabIds.push([...args.parkedTabIds].sort()) + return [...args.parkedTabIds].map((tabId) => { + const plan = args.coveragePlansByTabId.get(tabId) + const watchedPtyIds = (plan?.panes ?? []).flatMap((pane) => + pane.ptyId === null ? [] : [pane.ptyId] + ) + if (args.forcedTabIds.has(tabId)) { + return { status: 'forced', tabId, materialKey: plan?.materialKey ?? null, watchedPtyIds } + } + return mocks.failWatcherSync + ? { + status: 'failed', + tabId, + materialKey: plan?.materialKey ?? null, + reason: 'watcher-coverage-incomplete', + expectedPtyIds: watchedPtyIds, + watchedPtyIds: [] + } + : { status: 'covering', tabId, materialKey: plan?.materialKey ?? '', watchedPtyIds } + }) + } })) vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ @@ -54,10 +107,6 @@ import { TERMINAL_TAB_COLD_PARK_DELAY_MS, TERMINAL_TAB_HOT_RETAIN_MS } from './terminal-hidden-view-parking' -import { - TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - TERMINAL_TAB_PARK_FLIP_WINDOW_MS -} from './terminal-park-verdict-flip-telemetry' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' const WORKTREE_ID = 'wt-1' @@ -89,7 +138,10 @@ describe('useTerminalTabColdParking measure-clock contract', () => { vi.useRealTimers() mocks.exemptTabIds = new Set() mocks.exemptSelectCalls = 0 - mocks.watcherCoverage = true + mocks.planRevision = 0 + mocks.failWatcherSync = false + mocks.watcherSyncParkedTabIds = [] + mocks.ownershipLossListener = null mocks.storeState.terminalLayoutsByTabId = {} mocks.storeState.sleepingAgentSessionsByPaneKey = {} mocks.storeState.runtimeStatusByEnvironmentId = new Map() @@ -122,12 +174,57 @@ describe('useTerminalTabColdParking measure-clock contract', () => { } }) - // Why: the flip-damping pin removes the tab from the parked set, and every - // hysteresis deadline of a long-hidden tab is already past — so the pin - // deadline is the only wakeup that can ever re-park it. Without scheduling - // it, damping silently becomes a permanent unpark: the pane (~4-5MB) stays - // mounted for the life of the window, which is the renderer-OOM cluster. - it('re-parks a damped tab once the pin expires with no other store change', () => { + it('retries a rejected handoff only after the material plan changes', () => { + mocks.failWatcherSync = true + const { result, rerender } = renderHook( + (args: ReturnType) => useTerminalTabColdParking(args), + { initialProps: hookArgs(false) } + ) + act(() => { + vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) + }) + expect(result.current.size).toBe(0) + + mocks.failWatcherSync = false + act(() => { + rerender(hookArgs(false)) + }) + expect(result.current.size).toBe(0) + + mocks.planRevision += 1 + act(() => { + rerender(hookArgs(false)) + }) + expect(result.current).toEqual(new Set(['tab-2'])) + }) + + it('reveals an activation-deferred tab when its watcher handoff fails', () => { + mocks.failWatcherSync = true + const onHandoffFailed = vi.fn() + const args = { + ...hookArgs(false), + activationDeferredMountTabIds: new Set(['tab-2']), + onActivationDeferredWatcherHandoffFailed: onHandoffFailed + } + + const { result } = renderHook(() => useTerminalTabColdParking(args)) + + expect(result.current.has('tab-2')).toBe(false) + expect(onHandoffFailed).toHaveBeenCalledWith('tab-2') + }) + + it('starts an activation-deferred watcher in the first effect flush', () => { + renderHook(() => + useTerminalTabColdParking({ + ...hookArgs(false), + activationDeferredMountTabIds: new Set(['tab-2']) + }) + ) + + expect(mocks.watcherSyncParkedTabIds[0]).toEqual(['tab-2']) + }) + + it('remounts after established watcher ownership is lost', () => { const { result, rerender } = renderHook( (args: ReturnType) => useTerminalTabColdParking(args), { initialProps: hookArgs(false) } @@ -137,23 +234,16 @@ describe('useTerminalTabColdParking measure-clock contract', () => { }) expect(result.current).toEqual(new Set(['tab-2'])) - // Churn the coverage veto at render cadence — no clock advance, so every - // flip lands inside the burst window and damping engages. - for (let flip = 0; flip <= TERMINAL_TAB_PARK_FLIP_BURST_LIMIT + 1; flip += 1) { - mocks.watcherCoverage = flip % 2 === 1 - act(() => { - rerender(hookArgs(false)) - }) - } - mocks.watcherCoverage = true act(() => { - rerender(hookArgs(false)) + mocks.ownershipLossListener?.({ worktreeId: WORKTREE_ID, tabId: 'tab-2' }) }) expect(result.current.size).toBe(0) - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_PARK_FLIP_WINDOW_MS) - }) + act(() => rerender(hookArgs(false))) + expect(result.current.size).toBe(0) + + mocks.planRevision += 1 + act(() => rerender(hookArgs(false))) expect(result.current).toEqual(new Set(['tab-2'])) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 6a7a0e1a498..fd112573d06 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -1,79 +1,43 @@ -/** - * Per-tab hidden-view parking for TerminalPaneOverlayLayer. - * - * Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck - * timers, parked-set selection) and the parked byte-watcher reconciliation so - * the overlay layer only consumes the final parked tab set when deciding to - * render a slot as null. - */ -import { useEffect, useMemo, useRef, useState } from 'react' +/** Per-tab hidden-view parking coordinator for TerminalPaneOverlayLayer. */ +import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' +import { useShallow } from 'zustand/react/shallow' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '../../store' import { - findActivityTerminalPortal, - type ActivityTerminalPortalTarget -} from '../activity/activity-terminal-portal' -import { getTerminalTabColdParkRecheckDelayMs } from './terminal-cold-park-recheck-deadlines' -import { - TERMINAL_TAB_COLD_PARK_DELAY_MS, - selectPairedRuntimeParkingEnvironmentIds, - selectColdParkedTerminalTabs, - type TerminalTabColdParkCandidate -} from './terminal-hidden-view-parking' + getTerminalProviderSnapshotCapabilityRevision, + subscribeTerminalProviderSnapshotCapabilityRevision +} from '../terminal/terminal-provider-snapshot-capability' +import type { ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal' +import { selectPairedRuntimeParkingEnvironmentIds } from './terminal-hidden-view-parking' import { recordParkVerdictFlips, type ParkVerdictFlipRecord } from './terminal-park-verdict-flip-telemetry' -import { withholdUnparkableTerminalTabs } from './terminal-cold-park-withheld-tabs' -import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides' import { selectEvictionExemptTerminalTabIds, selectEvictionExemptTerminalTabLayoutKey } from './terminal-eviction-exempt-tabs' import { selectSleepingRecordParkExemptTabIds } from './sleeping-record-park-exemption' +import { createParkedTerminalWatcherTopologyKey } from './terminal-parked-watcher-reconciliation' import { - canWatcherCoverParkedTerminalTab, - disposeParkedTerminalWatchersForWorktree, - syncParkedTerminalTabWatchers -} from './terminal-parked-tab-watchers' - -type TerminalOverlayTabAssignment = { - groupId: string - isActiveInGroup: boolean -} + useTerminalTabColdParkCandidates, + type TerminalOverlayTabAssignment +} from './use-terminal-tab-cold-park-candidates' +import { useTerminalTabParkEpisodes } from './use-terminal-tab-park-episodes' const EMPTY_TAB_IDS: ReadonlySet = new Set() -function haveSameTerminalTabIds(left: ReadonlySet, right: ReadonlySet): boolean { - if (left.size !== right.size) { - return false - } - for (const id of left) { - if (!right.has(id)) { - return false - } - } - return true -} - export function useTerminalTabColdParking(args: { worktreeId: string terminalTabs: readonly TerminalTab[] assignments: ReadonlyMap isWorktreeActive: boolean - /** Worktree-level park verdict from Terminal.tsx. */ coldParkTerminalPanes: boolean - /** Retention-budget force-park (C1 slice B): unlike ordinary parks, the - * worktree may hold eviction-exempt tabs, whose panes must stay mounted — - * a remount would orphan their live pty (same carve-out as portals). */ isForceParked?: boolean - /** Hidden-measuring startup probe from Terminal.tsx — the panes must stay - * mounted for their first xterm fit, mirroring the worktree-level guard. */ shouldMeasureHiddenWorktree: boolean activityTerminalPortals: ActivityTerminalPortalTarget[] - /** Tabs cold activation keeps unmounted — parked-equivalent for watcher - * purposes. Targeted background restrictions intentionally stay bounded. */ activationDeferredMountTabIds?: ReadonlySet | null + onActivationDeferredWatcherHandoffFailed?: (tabId: string) => void }): ReadonlySet { const { worktreeId, @@ -84,7 +48,8 @@ export function useTerminalTabColdParking(args: { isForceParked = false, shouldMeasureHiddenWorktree, activityTerminalPortals, - activationDeferredMountTabIds + activationDeferredMountTabIds, + onActivationDeferredWatcherHandoffFailed } = args const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) const terminalParkingEnabled = useAppStore( @@ -98,6 +63,38 @@ export function useTerminalTabColdParking(args: { () => selectPairedRuntimeParkingEnvironmentIds(runtimeStatusByEnvironmentId), [runtimeStatusByEnvironmentId] ) + const watcherRestoreAuthorityKey = useMemo( + () => + JSON.stringify([ + terminalSshParkingEnabled, + [...pairedRuntimeParkingEnvironmentIds].sort((left, right) => left.localeCompare(right)) + ]), + [pairedRuntimeParkingEnvironmentIds, terminalSshParkingEnabled] + ) + const watcherLayoutsByTabId = useAppStore( + useShallow((state) => { + const layouts: typeof state.terminalLayoutsByTabId = {} + for (const terminalTab of terminalTabs) { + const layout = state.terminalLayoutsByTabId[terminalTab.id] + if (layout) { + layouts[terminalTab.id] = layout + } + } + return layouts + }) + ) + const watcherTopologyKey = useMemo( + () => + createParkedTerminalWatcherTopologyKey(worktreeId, terminalTabs, { + terminalLayoutsByTabId: watcherLayoutsByTabId + }), + [terminalTabs, watcherLayoutsByTabId, worktreeId] + ) + const providerCapabilityRevision = useSyncExternalStore( + subscribeTerminalProviderSnapshotCapabilityRevision, + getTerminalProviderSnapshotCapabilityRevision, + getTerminalProviderSnapshotCapabilityRevision + ) const sleepingAgentSessionsByPaneKey = useAppStore( (state) => state.sleepingAgentSessionsByPaneKey ) @@ -105,245 +102,54 @@ export function useTerminalTabColdParking(args: { () => selectSleepingRecordParkExemptTabIds(sleepingAgentSessionsByPaneKey, worktreeId), [sleepingAgentSessionsByPaneKey, worktreeId] ) - const terminalTabHiddenSinceRef = useRef(new Map()) - // Why (shared measure-clock contract with Terminal.tsx): tab hiddenSince - // survives a background-measure window so per-tab park deadlines stay in - // sync with the worktree retention/TTL clock, and a post-measure cool-down - // re-grants the hysteresis so measure end can't immediately re-park. - const wasMeasuringHiddenWorktreeRef = useRef(false) - const measureParkCooldownUntilRef = useRef(null) - const terminalTabParkingTimersRef = useRef(new Map()) - const parkVerdictRecordsRef = useRef(new Map()) - const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0) - const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( - () => new Set() - ) - useEffect(() => { - const timers = terminalTabParkingTimersRef.current - return () => { - for (const timer of timers.values()) { - window.clearTimeout(timer) - } - timers.clear() - } - }, []) - - // Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set - // selection, and one recheck timer per still-pending deadline so React - // re-renders exactly when the hysteresis elapses instead of polling. - useEffect(() => { - const timers = terminalTabParkingTimersRef.current - for (const timer of timers.values()) { - window.clearTimeout(timer) - } - timers.clear() - - const nowMs = Date.now() - const overrides = getTerminalParkingPolicyOverrides() - const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id)) - const portalTabIds = new Set( - activityTerminalPortals - .filter((portal) => portal.worktreeId === worktreeId) - .map((portal) => portal.tabId) - ) - for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) { - if (!currentTerminalTabIds.has(tabId)) { - terminalTabHiddenSinceRef.current.delete(tabId) - } - } - - // Why: measure end starts the re-park cool-down (worktree measure-clock - // contract) — hiddenSince is preserved through the window, so without the - // cool-down every past-deadline tab would re-park the instant it closes. - if (shouldMeasureHiddenWorktree) { - wasMeasuringHiddenWorktreeRef.current = true - } else { - if (wasMeasuringHiddenWorktreeRef.current) { - measureParkCooldownUntilRef.current = - nowMs + (overrides.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS) - } - wasMeasuringHiddenWorktreeRef.current = false - } - // Why: mirrors Terminal.tsx's worktree clock — a visible worktree ends the - // measure episode outright, so no re-park cool-down is owed. - if (isWorktreeActive) { - measureParkCooldownUntilRef.current = null - } - - const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => { - const assignment = assignments.get(terminalTab.id) - const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) - const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id) - // Why measuring preserves the clock: the startup probe still needs - // mounted panes (selection + render veto below), but deleting - // hiddenSince would restart the hysteresis AND desync per-tab deadlines - // from the worktree retention/TTL clock on every ~3s probe. - if (isVisible || hasActivityTerminalPortal) { - terminalTabHiddenSinceRef.current.delete(terminalTab.id) - } else if ( - !shouldMeasureHiddenWorktree && - !terminalTabHiddenSinceRef.current.has(terminalTab.id) - ) { - terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs) - } - return { - id: terminalTab.id, - ptyId: terminalTab.ptyId, - pendingActivationSpawn: terminalTab.pendingActivationSpawn, - isVisible, - hasActivityTerminalPortal, - hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null - } - }) - - const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({ - worktreeId, - terminalTabs: candidates, - pendingStartupByTabId, - parkingEnabled: terminalParkingEnabled, - nowMs, - parkCooldownUntilMs: measureParkCooldownUntilRef.current, - restorePolicy: { - sshParkingEnabled: terminalSshParkingEnabled, - pairedRuntimeParkingEnvironmentIds - }, - ...overrides - }) - const { parkedTabIds, parkVerdictPinUntilMsByTabId } = withholdUnparkableTerminalTabs({ - worktreeId, - terminalTabs, - coldParkedTabIds: nextColdParkedTerminalTabIds, - parkVerdictRecords: parkVerdictRecordsRef.current, - nowMs - }) - setColdParkedTerminalTabIds((current) => - haveSameTerminalTabIds(current, parkedTabIds) ? current : parkedTabIds - ) - - for (const candidate of candidates) { - if ( - candidate.isVisible || - candidate.hasActivityTerminalPortal || - parkedTabIds.has(candidate.id) - ) { - continue - } - const delayMs = getTerminalTabColdParkRecheckDelayMs({ - parkingEnabled: terminalParkingEnabled, - hiddenSinceMs: candidate.hiddenSinceMs, - parkCooldownUntilMs: measureParkCooldownUntilRef.current, - // Why: pin expiry may be the only remaining wakeup after damping stops churn. - parkVerdictPinUntilMs: parkVerdictPinUntilMsByTabId.get(candidate.id) ?? null, - nowMs, - ...overrides - }) - if (delayMs !== null && delayMs > 0) { - const tabId = candidate.id - const timer = window.setTimeout(() => { - timers.delete(tabId) - setTerminalTabParkingRevision((revision) => revision + 1) - }, delayMs) - timers.set(tabId, timer) - } - } - }, [ - activityTerminalPortals, + const coldParkCandidateTabIds = useTerminalTabColdParkCandidates({ + worktreeId, + terminalTabs, assignments, isWorktreeActive, - pendingStartupByTabId, - pairedRuntimeParkingEnvironmentIds, shouldMeasureHiddenWorktree, + activityTerminalPortals, + pendingStartupByTabId, terminalParkingEnabled, terminalSshParkingEnabled, - terminalTabParkingRevision, - terminalTabs, - worktreeId - ]) + pairedRuntimeParkingEnvironmentIds + }) - // Why subscribed: the exemption also reads layout leaf PTYs, which change - // without a terminalTabs change (split added, pty re-minted); gated on - // isForceParked so only force-parked worktrees build the key per store change. const evictionExemptLayoutKey = useAppStore((state) => isForceParked ? selectEvictionExemptTerminalTabLayoutKey(state, terminalTabs) : '' ) - // Why memoized: resolving an exemption re-reads the store and walks the - // layout tree per tab, so recompute only when the force-park verdict, the - // tabs, or their layout PTYs change — not on every assignment/park-set change - // below. - const evictionExemptTerminalTabIds = useMemo( - () => - isForceParked ? selectEvictionExemptTerminalTabIds(worktreeId, terminalTabs) : EMPTY_TAB_IDS, - // eslint-disable-next-line react-hooks/exhaustive-deps -- the layout key encodes the store fields the selector re-reads internally. + const evictionExemptionSnapshot = useMemo( + () => ({ + layoutKey: evictionExemptLayoutKey, + tabIds: isForceParked + ? selectEvictionExemptTerminalTabIds(worktreeId, terminalTabs) + : EMPTY_TAB_IDS + }), [evictionExemptLayoutKey, isForceParked, terminalTabs, worktreeId] ) - // Why: the rendered park verdict — worktree-level park (prop from - // Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and - // the watcher-sync effect must share this exact set so watcher lifecycle - // tracks the committed unmounts. - const parkedTerminalTabIds = useMemo(() => { - const parked = new Set() - for (const terminalTab of terminalTabs) { - const assignment = assignments.get(terminalTab.id) - const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) - const hasActivityTerminalPortal = - findActivityTerminalPortal(activityTerminalPortals, { - worktreeId, - tabId: terminalTab.id - }) !== null - if ( - (coldParkTerminalPanes || - (!isVisible && - coldParkedTerminalTabIds.has(terminalTab.id) && - // Why: a pane owning a sleeping-session record must stay mountable - // on an active worktree — parked it can never cold-restore, so the - // agent's resume strands until the user reveals the tab. Scoped to - // per-tab parks: the worktree-level park clears on activation. - !sleepingRecordOwnedTabIds.has(terminalTab.id))) && - !hasActivityTerminalPortal && - // Why: a force-parked worktree's eviction-exempt tabs keep their - // mounted panes — a remount would orphan their live pty. Scoped to - // force-parks: ordinary parks never contain exempt tabs (eligibility - // requires every tab restorable, so the memo is empty for them). - !evictionExemptTerminalTabIds.has(terminalTab.id) && - // Why: the hidden-measuring startup probe needs mounted panes; gate - // here too so the reveal lands in the same render that starts it. - !shouldMeasureHiddenWorktree - ) { - parked.add(terminalTab.id) - } - // Why: activation-deferred tabs render no pane regardless of the park - // policy, so watchers must own their side effects immediately. Targeted - // restrictions do not enter this set or add a new eager watcher burst. - if ( - activationDeferredMountTabIds?.has(terminalTab.id) && - !hasActivityTerminalPortal && - canWatcherCoverParkedTerminalTab(worktreeId, terminalTab) - ) { - parked.add(terminalTab.id) - } - } - return parked - }, [ - activityTerminalPortals, - assignments, - coldParkTerminalPanes, - coldParkedTerminalTabIds, - activationDeferredMountTabIds, - evictionExemptTerminalTabIds, - isWorktreeActive, - shouldMeasureHiddenWorktree, - sleepingRecordOwnedTabIds, + const parkedTerminalTabIds = useTerminalTabParkEpisodes({ + worktreeId, terminalTabs, - worktreeId - ]) + assignments, + isWorktreeActive, + coldParkTerminalPanes, + isForceParked, + shouldMeasureHiddenWorktree, + activityTerminalPortals, + activationDeferredMountTabIds, + onActivationDeferredWatcherHandoffFailed, + terminalParkingEnabled, + coldParkCandidateTabIds, + sleepingRecordOwnedTabIds, + evictionExemptTerminalTabIds: evictionExemptionSnapshot.tabIds, + providerCapabilityRevision, + watcherTopologyKey, + watcherRestoreAuthorityKey + }) - // Why: observation only — records whether the *rendered* park verdict churns, - // so a crash bundle can confirm or refute a park-flip update loop. Watching - // the pre-gate cold set instead would miss loops driven by coldParkTerminalPanes - // or the portal/measuring gates. Changes no verdict; see - // terminal-park-verdict-flip-telemetry.ts. + const parkVerdictRecordsRef = useRef(new Map()) useEffect(() => { recordParkVerdictFlips({ records: parkVerdictRecordsRef.current, @@ -353,22 +159,5 @@ export function useTerminalTabColdParking(args: { }) }, [parkedTerminalTabIds, terminalTabs]) - // Why: runs in the same effect flush as the commit that parked/revealed the - // panes — watcher disposal therefore lands before any PTY data IPC can - // reach a freshly remounted pane, and watcher start lands after the parked - // pane's unmount capture. - useEffect(() => { - syncParkedTerminalTabWatchers({ - worktreeId, - tabs: terminalTabs, - parkedTabIds: parkedTerminalTabIds, - // Why: activation-deferred tabs have no prior pane-owned title slot; - // pull main's title-only snapshot when their watcher starts. - restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined - }) - }, [activationDeferredMountTabIds, parkedTerminalTabIds, terminalTabs, worktreeId]) - - useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) - return parkedTerminalTabIds } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-park-episodes.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-park-episodes.ts new file mode 100644 index 00000000000..d93a8e50bb9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-park-episodes.ts @@ -0,0 +1,286 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { TerminalTab } from '../../../../shared/types' +import { + findActivityTerminalPortal, + type ActivityTerminalPortalTarget +} from '../activity/activity-terminal-portal' +import { + disposeParkedTerminalWatchersForWorktree, + planParkedTerminalTabWatcherCoverage, + subscribeParkedTerminalWatcherOwnershipLoss, + syncParkedTerminalTabWatchersWithAcknowledgements, + type TerminalParkedWatcherCoveragePlan +} from './terminal-parked-tab-watchers' +import { + acknowledgeTerminalParkEpisodeSet, + reconcileTerminalParkEpisodeSet, + selectCurrentTerminalParkEpisodeUnmounts, + type TerminalParkEpisodeSet +} from './terminal-park-episode-set' +import { terminalParkedWatcherPlanPtyIds } from './terminal-park-episode-lease' +import type { TerminalOverlayTabAssignment } from './use-terminal-tab-cold-park-candidates' + +type TerminalTabParkRequests = { + requestedTabIds: ReadonlySet + forcedTabIds: ReadonlySet +} + +function selectTerminalTabParkRequests(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + assignments: ReadonlyMap + isWorktreeActive: boolean + coldParkTerminalPanes: boolean + isForceParked: boolean + shouldMeasureHiddenWorktree: boolean + activityTerminalPortals: ActivityTerminalPortalTarget[] + activationDeferredMountTabIds?: ReadonlySet | null + terminalParkingEnabled: boolean + coldParkCandidateTabIds: ReadonlySet + sleepingRecordOwnedTabIds: ReadonlySet + evictionExemptTerminalTabIds: ReadonlySet +}): TerminalTabParkRequests { + const requestedTabIds = new Set() + const forcedTabIds = new Set() + for (const terminalTab of args.terminalTabs) { + const assignment = args.assignments.get(terminalTab.id) + const isVisible = Boolean(args.isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = + findActivityTerminalPortal(args.activityTerminalPortals, { + worktreeId: args.worktreeId, + tabId: terminalTab.id + }) !== null + if (hasActivityTerminalPortal) { + continue + } + const forcePark = + args.terminalParkingEnabled && + args.coldParkTerminalPanes && + args.isForceParked && + !args.shouldMeasureHiddenWorktree && + !args.evictionExemptTerminalTabIds.has(terminalTab.id) + const ordinaryPark = + args.terminalParkingEnabled && + !args.shouldMeasureHiddenWorktree && + ((args.coldParkTerminalPanes && !args.isForceParked) || + (!isVisible && + args.coldParkCandidateTabIds.has(terminalTab.id) && + !args.sleepingRecordOwnedTabIds.has(terminalTab.id))) + const activationDeferred = + args.terminalParkingEnabled && + args.activationDeferredMountTabIds?.has(terminalTab.id) === true && + !args.sleepingRecordOwnedTabIds.has(terminalTab.id) + if (forcePark || ordinaryPark || activationDeferred) { + requestedTabIds.add(terminalTab.id) + } + if (forcePark) { + forcedTabIds.add(terminalTab.id) + } + } + return { requestedTabIds, forcedTabIds } +} + +export function useTerminalTabParkEpisodes(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + assignments: ReadonlyMap + isWorktreeActive: boolean + coldParkTerminalPanes: boolean + isForceParked: boolean + shouldMeasureHiddenWorktree: boolean + activityTerminalPortals: ActivityTerminalPortalTarget[] + activationDeferredMountTabIds?: ReadonlySet | null + onActivationDeferredWatcherHandoffFailed?: (tabId: string) => void + terminalParkingEnabled: boolean + coldParkCandidateTabIds: ReadonlySet + sleepingRecordOwnedTabIds: ReadonlySet + evictionExemptTerminalTabIds: ReadonlySet + providerCapabilityRevision: number + watcherTopologyKey: string + watcherRestoreAuthorityKey: string +}): ReadonlySet { + const { + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + isForceParked, + shouldMeasureHiddenWorktree, + activityTerminalPortals, + activationDeferredMountTabIds, + onActivationDeferredWatcherHandoffFailed, + terminalParkingEnabled, + coldParkCandidateTabIds, + sleepingRecordOwnedTabIds, + evictionExemptTerminalTabIds, + providerCapabilityRevision, + watcherTopologyKey, + watcherRestoreAuthorityKey + } = args + const leasesRef = useRef(new Map()) + const [, setLeaseRevision] = useState(0) + + const parkRequests = useMemo( + () => + selectTerminalTabParkRequests({ + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + isForceParked, + shouldMeasureHiddenWorktree, + activityTerminalPortals, + activationDeferredMountTabIds, + terminalParkingEnabled, + coldParkCandidateTabIds, + sleepingRecordOwnedTabIds, + evictionExemptTerminalTabIds + }), + [ + activationDeferredMountTabIds, + activityTerminalPortals, + assignments, + coldParkCandidateTabIds, + coldParkTerminalPanes, + evictionExemptTerminalTabIds, + isForceParked, + isWorktreeActive, + shouldMeasureHiddenWorktree, + sleepingRecordOwnedTabIds, + terminalParkingEnabled, + terminalTabs, + worktreeId + ] + ) + + const coverageMaterialRevision = JSON.stringify([ + providerCapabilityRevision, + watcherTopologyKey, + watcherRestoreAuthorityKey + ]) + const coveragePlanSnapshot = useMemo(() => { + const plansByTabId = new Map() + for (const terminalTab of terminalTabs) { + if (parkRequests.requestedTabIds.has(terminalTab.id)) { + plansByTabId.set( + terminalTab.id, + planParkedTerminalTabWatcherCoverage(worktreeId, terminalTab) + ) + } + } + return { materialRevision: coverageMaterialRevision, plansByTabId } + }, [coverageMaterialRevision, parkRequests.requestedTabIds, terminalTabs, worktreeId]) + + const renderBaseLeases = leasesRef.current + const reconciledForRender = reconcileTerminalParkEpisodeSet({ + current: renderBaseLeases, + tabs: terminalTabs, + requestedTabIds: parkRequests.requestedTabIds, + forcedTabIds: parkRequests.forcedTabIds, + plan: (tab) => { + const plan = coveragePlanSnapshot.plansByTabId.get(tab.id) + if (!plan) { + throw new Error(`Missing terminal park plan for ${tab.id}`) + } + return plan + } + }) + const parkedTerminalTabIds = useMemo( + () => + selectCurrentTerminalParkEpisodeUnmounts({ + leases: reconciledForRender.leases, + plansByTabId: coveragePlanSnapshot.plansByTabId, + requestedTabIds: parkRequests.requestedTabIds, + forcedTabIds: parkRequests.forcedTabIds + }), + [coveragePlanSnapshot, parkRequests, reconciledForRender.leases] + ) + + useEffect(() => { + if (leasesRef.current === renderBaseLeases) { + leasesRef.current = reconciledForRender.leases + } + for (const tabId of activationDeferredMountTabIds ?? []) { + if (!parkedTerminalTabIds.has(tabId)) { + onActivationDeferredWatcherHandoffFailed?.(tabId) + } + } + }, [ + activationDeferredMountTabIds, + onActivationDeferredWatcherHandoffFailed, + parkedTerminalTabIds, + reconciledForRender.leases, + renderBaseLeases + ]) + + useEffect(() => { + const acknowledgements = syncParkedTerminalTabWatchersWithAcknowledgements({ + worktreeId, + tabs: terminalTabs, + parkedTabIds: parkedTerminalTabIds, + coveragePlansByTabId: coveragePlanSnapshot.plansByTabId, + forcedTabIds: parkRequests.forcedTabIds, + restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined + }) + const acknowledged = acknowledgeTerminalParkEpisodeSet(leasesRef.current, acknowledgements) + leasesRef.current = acknowledged.leases + if (!acknowledged.renderChanged) { + return + } + for (const acknowledgement of acknowledgements) { + if ( + acknowledgement.status === 'failed' && + activationDeferredMountTabIds?.has(acknowledgement.tabId) + ) { + onActivationDeferredWatcherHandoffFailed?.(acknowledgement.tabId) + } + } + setLeaseRevision((revision) => revision + 1) + }, [ + activationDeferredMountTabIds, + coveragePlanSnapshot, + onActivationDeferredWatcherHandoffFailed, + parkedTerminalTabIds, + parkRequests.forcedTabIds, + terminalTabs, + worktreeId + ]) + + useEffect( + () => + subscribeParkedTerminalWatcherOwnershipLoss((event) => { + if (event.worktreeId !== worktreeId) { + return + } + const lease = leasesRef.current.get(event.tabId) + if (!lease || lease.phase === 'forced') { + return + } + const acknowledged = acknowledgeTerminalParkEpisodeSet(leasesRef.current, [ + { + status: 'failed', + tabId: event.tabId, + materialKey: lease.plan.materialKey, + reason: 'watcher-ownership-lost', + expectedPtyIds: terminalParkedWatcherPlanPtyIds(lease.plan), + watchedPtyIds: [] + } + ]) + if (!acknowledged.renderChanged) { + return + } + leasesRef.current = acknowledged.leases + if (activationDeferredMountTabIds?.has(event.tabId)) { + onActivationDeferredWatcherHandoffFailed?.(event.tabId) + } + setLeaseRevision((revision) => revision + 1) + }), + [activationDeferredMountTabIds, onActivationDeferredWatcherHandoffFailed, worktreeId] + ) + + useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) + + return parkedTerminalTabIds +} diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts index b1c727f9e4e..86b4237451e 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearTerminalProviderSnapshotCapabilities, collectTerminalProviderSnapshotPtyIds, + getTerminalProviderSnapshotCapabilityRevision, + getTerminalProviderSnapshotCapabilityState, refreshTerminalProviderSnapshotCapabilities, + subscribeTerminalProviderSnapshotCapabilityRevision, synchronizeTerminalProviderSnapshotCapabilities, terminalProviderHasAuthoritativeSnapshot } from './terminal-provider-snapshot-capability' @@ -43,6 +46,24 @@ describe('terminal provider snapshot capabilities', () => { expect(resolve).toHaveBeenCalledWith(['current', 'legacy']) expect(terminalProviderHasAuthoritativeSnapshot('current')).toBe(true) expect(terminalProviderHasAuthoritativeSnapshot('legacy')).toBe(false) + expect(getTerminalProviderSnapshotCapabilityState('current')).toBe('authoritative') + expect(getTerminalProviderSnapshotCapabilityState('legacy')).toBe('unavailable') + expect(getTerminalProviderSnapshotCapabilityState('unresolved')).toBe('pending') + }) + + it('publishes one semantic revision for a resolved batch', async () => { + const listener = vi.fn() + const unsubscribe = subscribeTerminalProviderSnapshotCapabilityRevision(listener) + const before = getTerminalProviderSnapshotCapabilityRevision() + + await synchronizeTerminalProviderSnapshotCapabilities(['pty-1', 'pty-2'], async (ids) => + ids.map((id) => ({ id, authoritative: true })) + ) + await synchronizeTerminalProviderSnapshotCapabilities(['pty-1', 'pty-2'], async () => []) + + expect(getTerminalProviderSnapshotCapabilityRevision()).toBe(before + 1) + expect(listener).toHaveBeenCalledOnce() + unsubscribe() }) it('refreshes a pre-provider false after startup services become ready', async () => { diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts index 3c4e0b7b61b..058e2e16ac0 100644 --- a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts +++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.ts @@ -11,6 +11,7 @@ type SnapshotCapabilityBindingState = { } const authoritativeSnapshotByPtyId = new Map() +const capabilityRevisionListeners = new Set<() => void>() const unknownCapabilityRetryAtByPtyId = new Map() const unknownCapabilityAttemptsByPtyId = new Map() const UNKNOWN_CAPABILITY_RETRY_MS = 1_000 @@ -21,6 +22,30 @@ const CAPABILITY_RESOLUTION_TIMEOUT_MS = 1_000 let lastSynchronizedLivePtyIds: readonly string[] | null = null let earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY let synchronizationGeneration = 0 +let terminalProviderSnapshotCapabilityRevision = 0 + +export type TerminalProviderSnapshotCapabilityState = 'pending' | 'authoritative' | 'unavailable' + +function publishTerminalProviderSnapshotCapabilityChange(changed: boolean): void { + if (!changed) { + return + } + terminalProviderSnapshotCapabilityRevision += 1 + for (const listener of capabilityRevisionListeners) { + listener() + } +} + +function setTerminalProviderSnapshotCapability(ptyId: string, authoritative: boolean): boolean { + if ( + authoritativeSnapshotByPtyId.has(ptyId) && + authoritativeSnapshotByPtyId.get(ptyId) === authoritative + ) { + return false + } + authoritativeSnapshotByPtyId.set(ptyId, authoritative) + return true +} export function collectTerminalProviderSnapshotPtyIds( state: SnapshotCapabilityBindingState @@ -55,13 +80,13 @@ function refreshEarliestUnknownCapabilityRetry(): void { } // Unknown routes settle eager after bounded retries to avoid lifelong capability polling. -function backOffUnknownCapability(ptyId: string, nowMs: number): void { +function backOffUnknownCapability(ptyId: string, nowMs: number): boolean { const attempts = (unknownCapabilityAttemptsByPtyId.get(ptyId) ?? 0) + 1 if (attempts >= UNKNOWN_CAPABILITY_MAX_ATTEMPTS) { - authoritativeSnapshotByPtyId.set(ptyId, false) + const changed = setTerminalProviderSnapshotCapability(ptyId, false) unknownCapabilityAttemptsByPtyId.delete(ptyId) unknownCapabilityRetryAtByPtyId.delete(ptyId) - return + return changed } unknownCapabilityAttemptsByPtyId.set(ptyId, attempts) unknownCapabilityRetryAtByPtyId.set( @@ -69,6 +94,7 @@ function backOffUnknownCapability(ptyId: string, nowMs: number): void { nowMs + Math.min(UNKNOWN_CAPABILITY_RETRY_MS * 2 ** (attempts - 1), UNKNOWN_CAPABILITY_MAX_RETRY_MS) ) + return false } function unknownCapabilityRetryDelayMs(nowMs: number): number | null { @@ -112,9 +138,10 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( const generation = ++synchronizationGeneration lastSynchronizedLivePtyIds = livePtyIds const live = new Set(livePtyIds.filter((id) => id.length > 0)) + let capabilityChanged = false for (const cachedId of authoritativeSnapshotByPtyId.keys()) { if (!live.has(cachedId)) { - authoritativeSnapshotByPtyId.delete(cachedId) + capabilityChanged = authoritativeSnapshotByPtyId.delete(cachedId) || capabilityChanged } } for (const pendingId of unknownCapabilityRetryAtByPtyId.keys()) { @@ -123,6 +150,8 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( unknownCapabilityAttemptsByPtyId.delete(pendingId) } } + publishTerminalProviderSnapshotCapabilityChange(capabilityChanged) + capabilityChanged = false const missing = [...live].filter( (id) => @@ -132,9 +161,10 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( const resolve = resolveCapabilities ?? window.api.pty.getAuthoritativeBufferSnapshotCapabilities if (!resolve) { for (const id of missing) { - backOffUnknownCapability(id, nowMs) + capabilityChanged = backOffUnknownCapability(id, nowMs) || capabilityChanged } refreshEarliestUnknownCapabilityRetry() + publishTerminalProviderSnapshotCapabilityChange(capabilityChanged) return unknownCapabilityRetryDelayMs(nowMs) } for (let offset = 0; offset < missing.length; offset += 512) { @@ -149,7 +179,7 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( // Why: unknown capability must keep the pane mounted. Do not cache the // failure as supported; back off before retrying daemon startup. for (const id of batch) { - backOffUnknownCapability(id, nowMs) + capabilityChanged = backOffUnknownCapability(id, nowMs) || capabilityChanged } continue } @@ -158,7 +188,7 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( } if (!resolved) { for (const id of missing.slice(offset)) { - backOffUnknownCapability(id, nowMs) + capabilityChanged = backOffUnknownCapability(id, nowMs) || capabilityChanged } break } @@ -166,15 +196,17 @@ export async function synchronizeTerminalProviderSnapshotCapabilities( for (const id of batch) { const authoritative = resolvedById.get(id) if (typeof authoritative === 'boolean') { - authoritativeSnapshotByPtyId.set(id, authoritative) + capabilityChanged = + setTerminalProviderSnapshotCapability(id, authoritative) || capabilityChanged unknownCapabilityRetryAtByPtyId.delete(id) unknownCapabilityAttemptsByPtyId.delete(id) } else { - backOffUnknownCapability(id, nowMs) + capabilityChanged = backOffUnknownCapability(id, nowMs) || capabilityChanged } } } refreshEarliestUnknownCapabilityRetry() + publishTerminalProviderSnapshotCapabilityChange(capabilityChanged) return unknownCapabilityRetryDelayMs(observedAtMs === undefined ? Date.now() : nowMs) } @@ -183,12 +215,14 @@ export async function refreshTerminalProviderSnapshotCapabilities( resolveCapabilities?: SnapshotCapabilityResolver ): Promise { lastSynchronizedLivePtyIds = null + let capabilityChanged = false for (const id of livePtyIds) { - authoritativeSnapshotByPtyId.delete(id) + capabilityChanged = authoritativeSnapshotByPtyId.delete(id) || capabilityChanged unknownCapabilityRetryAtByPtyId.delete(id) unknownCapabilityAttemptsByPtyId.delete(id) } refreshEarliestUnknownCapabilityRetry() + publishTerminalProviderSnapshotCapabilityChange(capabilityChanged) return synchronizeTerminalProviderSnapshotCapabilities(livePtyIds, resolveCapabilities) } @@ -214,11 +248,31 @@ export function terminalProviderHasAuthoritativeSnapshot(ptyId: string): boolean return authoritativeSnapshotByPtyId.get(ptyId) === true } +export function getTerminalProviderSnapshotCapabilityState( + ptyId: string +): TerminalProviderSnapshotCapabilityState { + const authoritative = authoritativeSnapshotByPtyId.get(ptyId) + return authoritative === undefined ? 'pending' : authoritative ? 'authoritative' : 'unavailable' +} + +export function getTerminalProviderSnapshotCapabilityRevision(): number { + return terminalProviderSnapshotCapabilityRevision +} + +export function subscribeTerminalProviderSnapshotCapabilityRevision( + listener: () => void +): () => void { + capabilityRevisionListeners.add(listener) + return () => capabilityRevisionListeners.delete(listener) +} + export function clearTerminalProviderSnapshotCapabilities(): void { + const capabilityChanged = authoritativeSnapshotByPtyId.size > 0 authoritativeSnapshotByPtyId.clear() unknownCapabilityRetryAtByPtyId.clear() unknownCapabilityAttemptsByPtyId.clear() lastSynchronizedLivePtyIds = null earliestUnknownCapabilityRetryAtMs = Number.POSITIVE_INFINITY synchronizationGeneration += 1 + publishTerminalProviderSnapshotCapabilityChange(capabilityChanged) } diff --git a/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx index 06cdc4f84f1..c8c65386391 100644 --- a/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx +++ b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.test.tsx @@ -54,6 +54,14 @@ describe('useTerminalProviderSnapshotCapability', () => { hook.unmount() }) + it('publishes a render revision when capability authority resolves', async () => { + resolveCapabilities.mockResolvedValue([{ id: 'ssh:target@@pty-1', authoritative: true }]) + const hook = renderHook(() => useTerminalProviderSnapshotCapability(true)) + const initialRevision = hook.result.current + + await waitFor(() => expect(hook.result.current).toBeGreaterThan(initialRevision)) + }) + it('cancels an unknown-capability retry when the hook unmounts', async () => { vi.useFakeTimers() resolveCapabilities.mockResolvedValue([{ id: 'ssh:target@@pty-1', authoritative: null }]) diff --git a/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts index 46dd2f9b983..0daf7749956 100644 --- a/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts +++ b/src/renderer/src/components/terminal/use-terminal-provider-snapshot-capability.ts @@ -1,13 +1,20 @@ -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useSyncExternalStore } from 'react' import { useAppStore } from '@/store' import { collectTerminalProviderSnapshotPtyIds, + getTerminalProviderSnapshotCapabilityRevision, + subscribeTerminalProviderSnapshotCapabilityRevision, startTerminalProviderSnapshotCapabilitySynchronization } from './terminal-provider-snapshot-capability' -export function useTerminalProviderSnapshotCapability(enabled: boolean): void { +export function useTerminalProviderSnapshotCapability(enabled: boolean): number { const tabsByWorktree = useAppStore((state) => state.tabsByWorktree) const ptyIdsByTabId = useAppStore((state) => state.ptyIdsByTabId) + const capabilityRevision = useSyncExternalStore( + subscribeTerminalProviderSnapshotCapabilityRevision, + getTerminalProviderSnapshotCapabilityRevision, + getTerminalProviderSnapshotCapabilityRevision + ) const boundPtyIds = useMemo( () => collectTerminalProviderSnapshotPtyIds({ tabsByWorktree, ptyIdsByTabId }), [ptyIdsByTabId, tabsByWorktree] @@ -20,4 +27,6 @@ export function useTerminalProviderSnapshotCapability(enabled: boolean): void { } return startTerminalProviderSnapshotCapabilitySynchronization(boundPtyIds) }, [boundPtyIds, enabled]) + + return capabilityRevision } diff --git a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts index 5727f5474c6..b50861f212d 100644 --- a/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts +++ b/src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts @@ -100,7 +100,7 @@ describe('terminal tab retirement store boundary', () => { paneIdByPtyId: new Map([['pty-primary', 1]]), disposersByPtyId: new Map([['pty-primary', dispose]]) }) - capturedPanesByTabId.set('tab-1', { worktreeId: 'wt-1', panes: [] }) + capturedPanesByTabId.set('tab-1', { worktreeId: 'wt-1', generation: null, panes: [] }) store.getState().closeTab('tab-1') await vi.waitFor(() => expect(mockKill).toHaveBeenCalledTimes(5))