fix(terminal): harden parked watcher synchronization

This commit is contained in:
Neil
2026-08-09 19:58:18 -07:00
parent 49f0a2f5bd
commit e0c3ee5d35
6 changed files with 292 additions and 13 deletions
@@ -33,7 +33,9 @@ vi.mock('../../store', async () => {
activeGroupIdByWorktree: {} as Record<string, string | undefined>,
groupsByWorktree: {} as Record<string, TabGroup[]>,
pendingStartupByTabId: {} as Record<string, unknown>,
ptyIdsByTabId: {} as Record<string, string[]>,
runtimeStatusByEnvironmentId: new Map<string, unknown>(),
runtimePaneTitlesByTabId: {} as Record<string, Record<number, string>>,
settings: {} as Record<string, unknown>,
sleepingAgentSessionsByPaneKey: {} as Record<string, unknown>,
tabsByWorktree: {} as Record<string, TerminalTab[]>,
@@ -31,6 +31,7 @@ vi.mock('../../store', async () => {
const { create } = await import('zustand')
const useAppStore = create(() => ({
pendingStartupByTabId: {} as Record<string, unknown>,
ptyIdsByTabId: {} as Record<string, string[]>,
runtimeStatusByEnvironmentId: new Map<string, unknown>(),
settings: {} as Record<string, unknown>,
terminalLayoutsByTabId: {} as Record<string, unknown>,
@@ -0,0 +1,188 @@
/** @vitest-environment happy-dom */
import { act, StrictMode, useEffect } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const WORKTREE_ID = 'repo::/parked-watcher-sync'
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 OLD_SECOND_PTY_ID = `${WORKTREE_ID}@@session-2`
const NEW_SECOND_PTY_ID = `${WORKTREE_ID}@@session-3`
const harness = vi.hoisted(() => ({
syncCalls: 0,
disposeCalls: 0,
watchedPtyIds: new Set<string>()
}))
vi.mock('../../store', async () => {
const { create } = await import('zustand')
const useAppStore = create(() => ({
ptyIdsByTabId: {} as Record<string, string[]>,
runtimePaneTitlesByTabId: {} as Record<string, Record<number, string>>,
terminalLayoutsByTabId: {} as Record<string, TerminalLayoutSnapshot>
}))
return { useAppStore }
})
vi.mock('./terminal-parked-tab-watchers', async () => {
const { useAppStore } = await import('../../store')
return {
disposeParkedTerminalWatchersForWorktree: () => {
harness.disposeCalls += 1
harness.watchedPtyIds.clear()
},
syncParkedTerminalTabWatchers: (args: {
tabs: readonly TerminalTab[]
parkedTabIds: ReadonlySet<string>
}) => {
harness.syncCalls += 1
const state = useAppStore.getState()
harness.watchedPtyIds.clear()
for (const tab of args.tabs) {
if (!args.parkedTabIds.has(tab.id)) {
continue
}
const layoutPtyIds = Object.values(
state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}
)
for (const ptyId of layoutPtyIds.length > 0 ? layoutPtyIds : [tab.ptyId]) {
if (ptyId) {
harness.watchedPtyIds.add(ptyId)
}
}
}
}
}
})
import { useAppStore } from '../../store'
import { disposeParkedTerminalWatchersForWorktree } from './terminal-parked-tab-watchers'
import {
captureParkedTerminalPaneCandidates,
capturedPanesByTabId
} from './terminal-parked-watcher-registry'
import { useParkedTerminalWatcherSynchronization } from './use-parked-terminal-watcher-synchronization'
const terminalTabs = [{ id: TAB_ID, ptyId: FIRST_PTY_ID }] as TerminalTab[]
const parkedTabIds = new Set([TAB_ID])
function splitLayout(secondPtyId: string): TerminalLayoutSnapshot {
return {
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
}
}
}
function WatcherSynchronizationHarness(): null {
useParkedTerminalWatcherSynchronization({
worktreeId: WORKTREE_ID,
terminalTabs,
inputsKey: JSON.stringify([[TAB_ID, FIRST_PTY_ID, null]]),
assignmentsKey: JSON.stringify([[TAB_ID, 'group-1', false]]),
parkedTabIds
})
// Models the pre-fix sibling disposal effect that StrictMode replays after synchronization.
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(WORKTREE_ID), [])
return null
}
describe('parked terminal watcher synchronization', () => {
let container: HTMLDivElement
let root: Root | undefined
beforeEach(() => {
harness.syncCalls = 0
harness.disposeCalls = 0
harness.watchedPtyIds.clear()
useAppStore.setState({
ptyIdsByTabId: { [TAB_ID]: [FIRST_PTY_ID, OLD_SECOND_PTY_ID] },
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'active', 2: 'split' } },
terminalLayoutsByTabId: { [TAB_ID]: splitLayout(OLD_SECOND_PTY_ID) }
})
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
act(() => root?.unmount())
root = undefined
capturedPanesByTabId.delete(TAB_ID)
container.remove()
})
it('reinstates parked watchers after StrictMode effect replay cleanup', () => {
root = createRoot(container)
act(() => {
root?.render(
<StrictMode>
<WatcherSynchronizationHarness />
</StrictMode>
)
})
expect(harness.disposeCalls).toBeGreaterThanOrEqual(1)
expect(harness.syncCalls).toBe(2)
expect(harness.watchedPtyIds).toEqual(new Set([FIRST_PTY_ID, OLD_SECOND_PTY_ID]))
})
it('resynchronizes a parked split when only its detached PTY and layout change', () => {
root = createRoot(container)
act(() => {
root?.render(<WatcherSynchronizationHarness />)
})
expect(harness.watchedPtyIds).toContain(OLD_SECOND_PTY_ID)
act(() => {
useAppStore.setState({
ptyIdsByTabId: { [TAB_ID]: [FIRST_PTY_ID, NEW_SECOND_PTY_ID] },
terminalLayoutsByTabId: { [TAB_ID]: splitLayout(NEW_SECOND_PTY_ID) }
})
harness.watchedPtyIds.delete(OLD_SECOND_PTY_ID)
})
expect(harness.syncCalls).toBe(2)
expect(harness.watchedPtyIds).toEqual(new Set([FIRST_PTY_ID, NEW_SECOND_PTY_ID]))
act(() => {
useAppStore.setState({
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ active', 2: 'new split title' } }
})
})
expect(harness.syncCalls).toBe(2)
act(() => {
useAppStore.setState({
terminalLayoutsByTabId: {
[TAB_ID]: { ...splitLayout(NEW_SECOND_PTY_ID), activeLeafId: SECOND_LEAF_ID }
}
})
})
expect(harness.syncCalls).toBe(3)
captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [
{ ptyId: FIRST_PTY_ID, paneId: 10, leafId: FIRST_LEAF_ID, drivesTabTitle: false },
{ ptyId: NEW_SECOND_PTY_ID, paneId: 11, leafId: SECOND_LEAF_ID, drivesTabTitle: true }
])
act(() => {
root?.render(<WatcherSynchronizationHarness />)
})
expect(harness.syncCalls).toBe(4)
})
})
@@ -1,12 +1,27 @@
import { useEffect, useRef } from 'react'
import type { TerminalTab } from '../../../../shared/types'
import { syncParkedTerminalTabWatchers } from './terminal-parked-tab-watchers'
import { useShallow } from 'zustand/react/shallow'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import {
disposeParkedTerminalWatchersForWorktree,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'
import { capturedPanesByTabId } from './terminal-parked-watcher-registry'
type TerminalParkingAssignment = {
groupId: string
isActiveInGroup: boolean
}
const EMPTY_PTY_IDS: readonly string[] = []
type WatcherReconciliationStoreInputs = (
| readonly string[]
| TerminalLayoutSnapshot
| string
| null
)[]
export function getTerminalParkingInputsKey(terminalTabs: readonly TerminalTab[]): string {
return JSON.stringify(terminalTabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn]))
}
@@ -29,16 +44,51 @@ function getWatcherSynchronizationKey(args: {
assignmentsKey: string
parkedTabIds: ReadonlySet<string>
activationDeferredMountTabIds?: ReadonlySet<string> | null
reconciliationKey: string
}): string {
return JSON.stringify([
args.worktreeId,
args.inputsKey,
args.assignmentsKey,
Array.from(args.parkedTabIds),
Array.from(args.activationDeferredMountTabIds ?? []).sort()
Array.from(args.activationDeferredMountTabIds ?? []).sort(),
args.reconciliationKey
])
}
function getCapturedPaneKey(terminalTabs: readonly TerminalTab[]): unknown[] {
return terminalTabs.map((tab) => {
const capture = capturedPanesByTabId.get(tab.id)
return [
tab.id,
capture?.worktreeId ?? null,
capture?.panes.map((pane) => [pane.ptyId, pane.paneId, pane.leafId, pane.drivesTabTitle]) ??
[]
]
})
}
function getWatcherReconciliationKey(
terminalTabs: readonly TerminalTab[],
inputs: WatcherReconciliationStoreInputs
): string {
const tabs = terminalTabs.map((tab, index) => {
const offset = index * 3
const ptyIds = inputs[offset] as readonly string[]
const layout = inputs[offset + 1] as TerminalLayoutSnapshot | null
const titleSlotKey = inputs[offset + 2] as string
return [
tab.id,
ptyIds,
layout?.root ?? null,
layout?.activeLeafId ?? null,
layout?.ptyIdsByLeafId ?? null,
titleSlotKey
]
})
return JSON.stringify([tabs, getCapturedPaneKey(terminalTabs)])
}
export function useParkedTerminalWatcherSynchronization(args: {
worktreeId: string
terminalTabs: readonly TerminalTab[]
@@ -47,15 +97,39 @@ export function useParkedTerminalWatcherSynchronization(args: {
parkedTabIds: ReadonlySet<string>
activationDeferredMountTabIds?: ReadonlySet<string> | null
}): void {
const { worktreeId, terminalTabs, parkedTabIds, activationDeferredMountTabIds } = args
const synchronizationKey = getWatcherSynchronizationKey(args)
const {
worktreeId,
terminalTabs,
inputsKey,
assignmentsKey,
parkedTabIds,
activationDeferredMountTabIds
} = args
const reconciliationStoreInputs = useAppStore(
useShallow((state) =>
terminalTabs.flatMap((tab) => [
state.ptyIdsByTabId[tab.id] ?? EMPTY_PTY_IDS,
state.terminalLayoutsByTabId[tab.id] ?? null,
Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {}).join(',')
])
)
) as WatcherReconciliationStoreInputs
const reconciliationKey = getWatcherReconciliationKey(terminalTabs, reconciliationStoreInputs)
const synchronizationKey = getWatcherSynchronizationKey({ ...args, reconciliationKey })
const synchronizationKeyRef = useRef<string | null>(null)
useEffect(
() => () => {
synchronizationKeyRef.current = null
disposeParkedTerminalWatchersForWorktree(worktreeId)
},
[worktreeId]
)
useEffect(() => {
if (synchronizationKeyRef.current === synchronizationKey) {
return
}
synchronizationKeyRef.current = synchronizationKey
syncParkedTerminalTabWatchers({
worktreeId,
tabs: terminalTabs,
@@ -63,5 +137,22 @@ export function useParkedTerminalWatcherSynchronization(args: {
// Why: activation-deferred tabs have no prior pane-owned title slot.
restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined
})
}, [activationDeferredMountTabIds, parkedTabIds, synchronizationKey, terminalTabs, worktreeId])
synchronizationKeyRef.current = getWatcherSynchronizationKey({
worktreeId,
inputsKey,
assignmentsKey,
parkedTabIds,
activationDeferredMountTabIds,
reconciliationKey: getWatcherReconciliationKey(terminalTabs, reconciliationStoreInputs)
})
}, [
activationDeferredMountTabIds,
assignmentsKey,
inputsKey,
parkedTabIds,
reconciliationStoreInputs,
synchronizationKey,
terminalTabs,
worktreeId
])
}
@@ -6,7 +6,9 @@ import type { TerminalTab } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
storeState: {
pendingStartupByTabId: {} as Record<string, unknown>,
ptyIdsByTabId: {} as Record<string, string[]>,
runtimeStatusByEnvironmentId: new Map(),
runtimePaneTitlesByTabId: {} as Record<string, Record<number, string>>,
settings: {} as Record<string, unknown>,
terminalLayoutsByTabId: {} as Record<string, { ptyIdsByLeafId?: Record<string, string> }>,
sleepingAgentSessionsByPaneKey: {} as Record<
@@ -31,10 +31,7 @@ import {
selectEvictionExemptTerminalTabLayoutKey
} from './terminal-eviction-exempt-tabs'
import { selectSleepingRecordParkExemptTabIds } from './sleeping-record-park-exemption'
import {
canWatcherCoverParkedTerminalTab,
disposeParkedTerminalWatchersForWorktree
} from './terminal-parked-tab-watchers'
import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers'
import {
getTerminalParkingAssignmentsKey,
getTerminalParkingInputsKey,
@@ -379,7 +376,5 @@ export function useTerminalTabColdParking(args: {
activationDeferredMountTabIds
})
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId])
return parkedTerminalTabIds
}