mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(terminal): harden deferred activation lifecycle
This commit is contained in:
@@ -61,7 +61,6 @@ import { resolveRepairedActiveTerminalTabId } from './terminal/active-terminal-r
|
||||
import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility'
|
||||
import {
|
||||
applyBackgroundMountTabRestriction,
|
||||
collectDeferredMountTabIds,
|
||||
planColdActivationTabDeferral,
|
||||
pruneClosedBackgroundMountTabs,
|
||||
revealActivationDeferredTabs,
|
||||
@@ -85,6 +84,7 @@ import {
|
||||
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
disposeAllParkedTerminalWatchers,
|
||||
pruneParkedTerminalWatchers,
|
||||
shouldDeferParkedPtyExitTabClose,
|
||||
syncParkedTerminalTabWatchers
|
||||
@@ -785,6 +785,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
// wake/resume) must not instantiate a TerminalPane per saved tab. A worktree
|
||||
// absent from this map mounts all of its tabs.
|
||||
const backgroundMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
|
||||
// Why: targeted background mounts share the allowed-tab map above, but only
|
||||
// cold activation deferral should immediately create watcher coverage for
|
||||
// every unmounted tab.
|
||||
const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
|
||||
// Why: the cold-activation deferral decision must run once per activation
|
||||
// transition, not on every re-render of an already-active worktree.
|
||||
const lastActivationWorktreeIdRef = useRef<string | null>(null)
|
||||
@@ -799,6 +803,18 @@ function Terminal(): React.JSX.Element | null {
|
||||
worktreeId,
|
||||
detail.tabIds
|
||||
)
|
||||
// Why: a targeted wake can reveal a tab that was deferred by an earlier
|
||||
// user activation. Remove it from watcher ownership before its pane mounts.
|
||||
const worktreeTabIds = (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).map(
|
||||
(tab) => tab.id
|
||||
)
|
||||
revealActivationDeferredTabs({
|
||||
restrictions: backgroundMountTabIdsByWorktreeRef.current,
|
||||
deferredMountTabIdsByWorktree: activationDeferredMountTabIdsByWorktreeRef.current,
|
||||
worktreeId,
|
||||
allTabIds: worktreeTabIds,
|
||||
immediateTabIds: new Set(detail.tabIds ?? worktreeTabIds)
|
||||
})
|
||||
scheduleBackgroundTerminalWorktreeMeasure({
|
||||
mountedWorktreeIds: mountedWorktreeIdsRef.current,
|
||||
measurableBackgroundWorktreeIds: measurableBackgroundWorktreeIdsRef.current,
|
||||
@@ -1024,6 +1040,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
const tabById = new Map(worktreeTabs.map((tab) => [tab.id, tab]))
|
||||
planColdActivationTabDeferral({
|
||||
restrictions: backgroundMountTabIdsByWorktreeRef.current,
|
||||
deferredMountTabIdsByWorktree: activationDeferredMountTabIdsByWorktreeRef.current,
|
||||
worktreeId: renderedActiveWorktreeId,
|
||||
allTabIds: worktreeTabs.map((tab) => tab.id),
|
||||
isTabLive: hasRegisteredRuntimeTerminalTab,
|
||||
@@ -1046,9 +1063,19 @@ function Terminal(): React.JSX.Element | null {
|
||||
// Why: flipping either kill switch while active must restore the eager
|
||||
// mount behavior immediately, not strand an old activation restriction.
|
||||
backgroundMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId)
|
||||
activationDeferredMountTabIdsByWorktreeRef.current.delete(renderedActiveWorktreeId)
|
||||
} else {
|
||||
// Why: tabs added after activation never passed the original coverage
|
||||
// gate. Uncoverable/no-PTY tabs must mount now so they can spawn or keep
|
||||
// their non-snapshot-backed live transport.
|
||||
for (const tab of worktreeTabs) {
|
||||
if (!canWatcherCoverParkedTerminalTab(renderedActiveWorktreeId, tab)) {
|
||||
immediateTabIds.add(tab.id)
|
||||
}
|
||||
}
|
||||
revealActivationDeferredTabs({
|
||||
restrictions: backgroundMountTabIdsByWorktreeRef.current,
|
||||
deferredMountTabIdsByWorktree: activationDeferredMountTabIdsByWorktreeRef.current,
|
||||
worktreeId: renderedActiveWorktreeId,
|
||||
allTabIds: worktreeTabs.map((tab) => tab.id),
|
||||
immediateTabIds
|
||||
@@ -1063,7 +1090,8 @@ function Terminal(): React.JSX.Element | null {
|
||||
pruneClosedBackgroundMountTabs(
|
||||
backgroundMountTabIdsByWorktreeRef.current,
|
||||
mountedWorktreeIdsRef.current,
|
||||
tabsByWorktree
|
||||
tabsByWorktree,
|
||||
activationDeferredMountTabIdsByWorktreeRef.current
|
||||
)
|
||||
// Prune IDs of worktrees that no longer exist (deleted/removed)
|
||||
const allWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id))
|
||||
@@ -1071,6 +1099,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
if (!allWorktreeIds.has(id)) {
|
||||
mountedWorktreeIdsRef.current.delete(id)
|
||||
backgroundMountTabIdsByWorktreeRef.current.delete(id)
|
||||
activationDeferredMountTabIdsByWorktreeRef.current.delete(id)
|
||||
}
|
||||
}
|
||||
const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout(
|
||||
@@ -1115,16 +1144,14 @@ function Terminal(): React.JSX.Element | null {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: mount-restricted (activation-deferred / targeted-background)
|
||||
// tabs are unmounted like parked ones; the same byte watchers own
|
||||
// their side effects until first reveal.
|
||||
deferredTabIds = collectDeferredMountTabIds(
|
||||
backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null,
|
||||
tabs.map((tab) => tab.id)
|
||||
)
|
||||
// Why: activation-deferred tabs are unmounted like parked ones; the
|
||||
// same byte watchers own their side effects until first reveal.
|
||||
// Targeted restrictions keep their existing delayed parking policy.
|
||||
deferredTabIds =
|
||||
activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
|
||||
for (const tab of tabs) {
|
||||
if (
|
||||
deferredTabIds.has(tab.id) &&
|
||||
deferredTabIds?.has(tab.id) &&
|
||||
!parkedTabIds.has(tab.id) &&
|
||||
canWatcherCoverParkedTerminalTab(workspace.id, tab) &&
|
||||
!findActivityTerminalPortal(activityTerminalPortals, {
|
||||
@@ -1152,19 +1179,25 @@ function Terminal(): React.JSX.Element | null {
|
||||
activeTabId,
|
||||
activeView,
|
||||
activityTerminalPortals,
|
||||
activeTabIdByWorktree,
|
||||
anyMountedWorktreeHasLayout,
|
||||
backgroundMountRevision,
|
||||
getEffectiveLayoutForWorktree,
|
||||
groupsByWorktree,
|
||||
parkedTerminalWorktreeIds,
|
||||
pendingStartupByTabId,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalTitleSnapshotAuthorityEnabled,
|
||||
workspaceSessionReady,
|
||||
workspaceSurfaces
|
||||
])
|
||||
// Why: symmetric with useTerminalTabColdParking's unmount cleanup — when
|
||||
// the terminal host unmounts, no reconciliation effect will run again, so
|
||||
// dispose every remaining parked watcher here (overlay-layer children have
|
||||
// already disposed theirs by the time this parent cleanup runs).
|
||||
useEffect(() => () => pruneParkedTerminalWatchers(new Set()), [])
|
||||
useEffect(() => () => disposeAllParkedTerminalWatchers(), [])
|
||||
// Auto-create first tab when worktree activates
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
@@ -2219,6 +2252,9 @@ function Terminal(): React.JSX.Element | null {
|
||||
backgroundMountTabIds={
|
||||
backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
|
||||
}
|
||||
activationDeferredMountTabIds={
|
||||
activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -2516,7 +2552,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
shouldMeasureHiddenWorktree,
|
||||
shouldColdParkTerminalPanes,
|
||||
activityTerminalPortals,
|
||||
backgroundMountTabIds
|
||||
backgroundMountTabIds,
|
||||
activationDeferredMountTabIds
|
||||
}: {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
@@ -2527,6 +2564,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
shouldColdParkTerminalPanes: boolean
|
||||
activityTerminalPortals: ActivityTerminalPortalTarget[]
|
||||
backgroundMountTabIds: ReadonlySet<string> | null
|
||||
activationDeferredMountTabIds: ReadonlySet<string> | null
|
||||
}): React.JSX.Element {
|
||||
const browserPageIds = useAppStore(
|
||||
useShallow((state) =>
|
||||
@@ -2569,6 +2607,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
backgroundMountTabIds={backgroundMountTabIds}
|
||||
activationDeferredMountTabIds={activationDeferredMountTabIds}
|
||||
/>
|
||||
{isVisible || backgroundMountTabIds === null ? (
|
||||
<>
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
} from '../activity/activity-terminal-portal'
|
||||
import TerminalPane from './TerminalPane'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import {
|
||||
collectDeferredMountTabIds,
|
||||
shouldMountBackgroundWorktreeTab
|
||||
} from '../terminal/background-terminal-worktree-mount'
|
||||
import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount'
|
||||
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
|
||||
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
|
||||
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
|
||||
@@ -64,7 +61,6 @@ type TerminalOverlaySlotProps = {
|
||||
activityTerminalPortal: ActivityTerminalPortalTarget | null
|
||||
onFocusOwningGroup: ((groupId: string) => void) | undefined
|
||||
consumeSuppressedPtyExit: (ptyId: string) => boolean
|
||||
closeTab: (tabId: string) => void
|
||||
leaveWorktreeIfEmpty: () => void
|
||||
}
|
||||
|
||||
@@ -81,7 +77,6 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
activityTerminalPortal,
|
||||
onFocusOwningGroup,
|
||||
consumeSuppressedPtyExit,
|
||||
closeTab,
|
||||
leaveWorktreeIfEmpty
|
||||
}: TerminalOverlaySlotProps): React.JSX.Element {
|
||||
const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined
|
||||
@@ -249,15 +244,13 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTab(terminalTabId)
|
||||
leaveWorktreeIfEmpty()
|
||||
closeTerminalTab(terminalTabId, { onClosed: leaveWorktreeIfEmpty })
|
||||
}}
|
||||
onCloseTab={() => {
|
||||
// Why: route through closeTerminalTab (not the raw store closeTab) so a
|
||||
// pinned tab hits the confirmation guard. The overlay's direct
|
||||
// store.closeTab was the path that closed pinned terminals silently.
|
||||
closeTerminalTab(terminalTabId)
|
||||
leaveWorktreeIfEmpty()
|
||||
closeTerminalTab(terminalTabId, { onClosed: leaveWorktreeIfEmpty })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -293,7 +286,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
coldParkTerminalPanes = false,
|
||||
shouldMeasureHiddenWorktree = false,
|
||||
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS,
|
||||
backgroundMountTabIds = null
|
||||
backgroundMountTabIds = null,
|
||||
activationDeferredMountTabIds = null
|
||||
}: {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
@@ -304,6 +298,9 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
/** Non-null for targeted background mounts: only these terminal tabs get a
|
||||
* TerminalPane, so waking one slept agent does not connect every saved tab. */
|
||||
backgroundMountTabIds?: ReadonlySet<string> | null
|
||||
/** Only cold-activation deferred tabs receive immediate parked watcher
|
||||
* coverage; targeted mounts keep their existing delayed parking policy. */
|
||||
activationDeferredMountTabIds?: ReadonlySet<string> | null
|
||||
}): React.JSX.Element | null {
|
||||
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
|
||||
useShallow((state) => ({
|
||||
@@ -315,7 +312,6 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
)
|
||||
const focusGroup = useAppStore((state) => state.focusGroup)
|
||||
const consumeSuppressedPtyExit = useAppStore((state) => state.consumeSuppressedPtyExit)
|
||||
const closeTab = useAppStore((state) => state.closeTab)
|
||||
const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
|
||||
const reconcileWorktreeTabModel = useAppStore((state) => state.reconcileWorktreeTabModel)
|
||||
|
||||
@@ -324,9 +320,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
// Why: legacy TabGroupPanel routed terminal closes through
|
||||
// commands.closeItem → leaveWorktreeIfEmpty, which deselected the worktree
|
||||
// when the last renderable tab closed and sent the user back to Landing.
|
||||
// The overlay layer calls store.closeTab directly, so replicate that
|
||||
// post-close check here; otherwise closing the last terminal leaves an
|
||||
// empty TabGroupPanel body selected.
|
||||
// Run this only after the guarded close resolves; a pending/cancelled pinned
|
||||
// close must leave the worktree and paired-web mirror selected.
|
||||
const leaveWorktreeIfEmpty = useCallback(() => {
|
||||
const state = useAppStore.getState()
|
||||
if (state.activeWorktreeId !== worktreeId) {
|
||||
@@ -366,16 +361,6 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
return entries
|
||||
}, [groupActiveTabById, unifiedTabs])
|
||||
|
||||
// Why: mount-restricted tabs render no pane (the filter below), so they
|
||||
// need parked-watcher coverage exactly like cold-parked tabs.
|
||||
const deferredMountTabIds = useMemo(
|
||||
() =>
|
||||
collectDeferredMountTabIds(
|
||||
backgroundMountTabIds,
|
||||
terminalTabs.map((tab) => tab.id)
|
||||
),
|
||||
[backgroundMountTabIds, terminalTabs]
|
||||
)
|
||||
const parkedTerminalTabIds = useTerminalTabColdParking({
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
@@ -384,7 +369,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
coldParkTerminalPanes,
|
||||
shouldMeasureHiddenWorktree,
|
||||
activityTerminalPortals,
|
||||
deferredMountTabIds
|
||||
activationDeferredMountTabIds
|
||||
})
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -425,7 +410,6 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
activityTerminalPortal={activityTerminalPortal}
|
||||
onFocusOwningGroup={focusOwningGroup}
|
||||
consumeSuppressedPtyExit={consumeSuppressedPtyExit}
|
||||
closeTab={closeTab}
|
||||
leaveWorktreeIfEmpty={leaveWorktreeIfEmpty}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4957,6 +4957,7 @@ describe('connectPanePty', () => {
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
|
||||
capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ')
|
||||
await flushAsyncTicks(1)
|
||||
|
||||
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey)
|
||||
expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled()
|
||||
@@ -4998,6 +4999,7 @@ describe('connectPanePty', () => {
|
||||
expect.objectContaining({ agentType: 'codex' })
|
||||
)
|
||||
capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ')
|
||||
await flushAsyncTicks(1)
|
||||
|
||||
expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey)
|
||||
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
|
||||
@@ -7836,6 +7838,65 @@ describe('connectPanePty', () => {
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the reattach snapshot before live bytes delivered during the spawn reply', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('tab-pty')
|
||||
transport.connect.mockImplementation(
|
||||
async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
|
||||
if (!sessionId) {
|
||||
return null
|
||||
}
|
||||
// Why: the real dispatcher drains bytes emitted after snapshot capture
|
||||
// as soon as the spawn IPC resolves, before connect() returns.
|
||||
callbacks?.onData?.('post-snapshot-live')
|
||||
return { id: sessionId, snapshot: 'authoritative-snapshot' }
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
|
||||
})
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
const snapshotIndex = writes.indexOf('authoritative-snapshot')
|
||||
const liveIndex = writes.indexOf('post-snapshot-live')
|
||||
expect(snapshotIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(liveIndex).toBeGreaterThan(snapshotIndex)
|
||||
})
|
||||
|
||||
it('does not fresh-spawn after a dead deferred session delivers its buffered exit', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('tab-pty')
|
||||
transport.connect.mockImplementation(
|
||||
async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
|
||||
if (!sessionId) {
|
||||
return 'unexpected-fresh-pty'
|
||||
}
|
||||
callbacks?.onData?.('dead-session-final-frame')
|
||||
return { id: sessionId, exitedBeforeAttach: true }
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const pane = createPane(1)
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
|
||||
})
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(transport.connect).toHaveBeenCalledTimes(1)
|
||||
expect(writes).toContain('dead-session-final-frame')
|
||||
expect(deps.updateTabPtyId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: Phase 6 deleted the hidden-skip eligibility grammar. With the kill
|
||||
// switch off, EVERY hidden chunk — plain, control-heavy, rich glyphs,
|
||||
// synchronized frames, embedded queries — rides the bounded background
|
||||
|
||||
@@ -3117,6 +3117,10 @@ export function connectPanePty(
|
||||
const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0
|
||||
let lastTerminalInputAt = Number.NEGATIVE_INFINITY
|
||||
let hasReceivedPtyOutput = false
|
||||
let deferredReattachLiveData: { data: string; meta?: PtyDataMeta }[] | null = null
|
||||
let deferredReattachLiveDataChars = 0
|
||||
const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024
|
||||
const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024
|
||||
const markTerminalInputSent = (): void => {
|
||||
lastTerminalInputAt = performance.now()
|
||||
}
|
||||
@@ -6275,6 +6279,26 @@ export function connectPanePty(
|
||||
}
|
||||
|
||||
const dataCallback = (data: string, meta?: PtyDataMeta): void => {
|
||||
if (deferredReattachLiveData !== null) {
|
||||
deferredReattachLiveData.push({ data, ...(meta ? { meta } : {}) })
|
||||
deferredReattachLiveDataChars += data.length
|
||||
let dropped = false
|
||||
while (
|
||||
deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS ||
|
||||
deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS
|
||||
) {
|
||||
const removed = deferredReattachLiveData.shift()
|
||||
deferredReattachLiveDataChars -= removed?.data.length ?? 0
|
||||
dropped = true
|
||||
}
|
||||
if (dropped && deferredReattachLiveData[0]) {
|
||||
deferredReattachLiveData[0].meta = {
|
||||
...deferredReattachLiveData[0].meta,
|
||||
droppedOutput: true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (data.length > 0) {
|
||||
hasReceivedPtyOutput = true
|
||||
recordAgentHibernationPaneOutput(cacheKey)
|
||||
@@ -6464,17 +6488,44 @@ export function connectPanePty(
|
||||
}
|
||||
})
|
||||
|
||||
const beginReattachLiveDataDeferral = (): void => {
|
||||
deferredReattachLiveData = []
|
||||
deferredReattachLiveDataChars = 0
|
||||
}
|
||||
|
||||
const finishReattachLiveDataDeferral = (deliver: boolean): void => {
|
||||
const chunks = deferredReattachLiveData
|
||||
deferredReattachLiveData = null
|
||||
deferredReattachLiveDataChars = 0
|
||||
if (!deliver || disposed || !chunks) {
|
||||
return
|
||||
}
|
||||
// Why: createOrAttach snapshots precede bytes emitted before its IPC
|
||||
// reply. Paint the authoritative replay first, then admit those live
|
||||
// chunks so the replay clear cannot erase newer output.
|
||||
for (const chunk of chunks) {
|
||||
dataCallback(chunk.data, chunk.meta)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReattachResult = (
|
||||
result: PtyConnectResult | string | void,
|
||||
staleSessionId?: string | null,
|
||||
coldRestoreStartup?: ColdRestoreAgentResumeStartup | null
|
||||
): void => {
|
||||
): boolean => {
|
||||
if (disposed) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
const connectResult =
|
||||
result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null
|
||||
|
||||
if (connectResult?.exitedBeforeAttach) {
|
||||
// Why: the transport already delivered the dead session's final frame
|
||||
// and exit. Treat it as terminal state, not a failed reattach that
|
||||
// should silently replace a cancelled pinned exit with a fresh shell.
|
||||
return true
|
||||
}
|
||||
|
||||
const ptyId =
|
||||
connectResult?.id ?? (typeof result === 'string' ? result : transport.getPtyId())
|
||||
if (!ptyId) {
|
||||
@@ -6498,7 +6549,7 @@ export function connectPanePty(
|
||||
startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
registerEffectiveLaunchConfig(connectResult?.launchConfig, {
|
||||
...(coldRestoreStartup ? { launchToken: coldRestoreStartup.launchToken } : {}),
|
||||
@@ -6523,7 +6574,7 @@ export function connectPanePty(
|
||||
startFreshColdRestoreAgentResume(coldRestoreStartup, {
|
||||
forceBlankRestoredViewport: true
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
setPanePtyFitBinding(ptyId)
|
||||
reportPanePtyVisibility(ptyId, deps.isVisibleRef.current)
|
||||
@@ -6673,6 +6724,7 @@ export function connectPanePty(
|
||||
scheduleReattachIdleAgentCursorReset()
|
||||
|
||||
scheduleRuntimeGraphSync()
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: if this tab has a deferred SSH session ID, trigger the SSH
|
||||
@@ -6867,6 +6919,7 @@ export function connectPanePty(
|
||||
const coldRestoreStartup = buildColdRestoreAgentResumeStartup()
|
||||
clearPaneMode2031State()
|
||||
clearHiddenOutputRestoreState()
|
||||
beginReattachLiveDataDeferral()
|
||||
const reattachPromise = transport.connect({
|
||||
url: '',
|
||||
cols,
|
||||
@@ -6908,6 +6961,7 @@ export function connectPanePty(
|
||||
: 'undefined'
|
||||
)
|
||||
if (!result && expiredReattachError) {
|
||||
finishReattachLiveDataDeferral(false)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
@@ -6922,7 +6976,8 @@ export function connectPanePty(
|
||||
})
|
||||
return
|
||||
}
|
||||
handleReattachResult(result, pendingSessionId, coldRestoreStartup)
|
||||
const accepted = handleReattachResult(result, pendingSessionId, coldRestoreStartup)
|
||||
finishReattachLiveDataDeferral(accepted)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
if (!isRemoteRuntimePtyId(pendingSessionId)) {
|
||||
@@ -6931,6 +6986,7 @@ export function connectPanePty(
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
finishReattachLiveDataDeferral(false)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
@@ -7053,6 +7109,7 @@ export function connectPanePty(
|
||||
|
||||
let expiredReattachError = false
|
||||
const coldRestoreStartup = buildColdRestoreAgentResumeStartup()
|
||||
beginReattachLiveDataDeferral()
|
||||
const reattachPromise = transport.connect({
|
||||
url: '',
|
||||
cols,
|
||||
@@ -7084,6 +7141,7 @@ export function connectPanePty(
|
||||
void Promise.resolve(reattachPromise)
|
||||
.then(async (result) => {
|
||||
if (!result && expiredReattachError) {
|
||||
finishReattachLiveDataDeferral(false)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
@@ -7098,7 +7156,12 @@ export function connectPanePty(
|
||||
})
|
||||
return
|
||||
}
|
||||
handleReattachResult(result, deferredReattachSessionId, coldRestoreStartup)
|
||||
const accepted = handleReattachResult(
|
||||
result,
|
||||
deferredReattachSessionId,
|
||||
coldRestoreStartup
|
||||
)
|
||||
finishReattachLiveDataDeferral(accepted)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
if (!isRemoteRuntimePtyId(deferredReattachSessionId)) {
|
||||
@@ -7107,6 +7170,7 @@ export function connectPanePty(
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
finishReattachLiveDataDeferral(false)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {})
|
||||
|
||||
@@ -309,6 +309,31 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => {
|
||||
transport.disconnect()
|
||||
})
|
||||
|
||||
it('preserves buffered live-session data when reconnect re-admits a consumed id', async () => {
|
||||
const { consumePreHandlerPtyState } = await import('./pty-pre-handler-buffer')
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const { ensurePtyDispatcher } = await import('./pty-dispatcher')
|
||||
const onData = vi.fn()
|
||||
|
||||
ensurePtyDispatcher()
|
||||
consumePreHandlerPtyState('pty-persisted')
|
||||
dispatcherCallback?.({ id: 'pty-persisted', data: 'live before pane\r\n', rawLength: 18 })
|
||||
;(window.api.pty.spawn as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: 'pty-persisted',
|
||||
isReattach: true
|
||||
})
|
||||
|
||||
const transport = createIpcPtyTransport()
|
||||
await transport.connect({
|
||||
url: '',
|
||||
sessionId: 'pty-persisted',
|
||||
callbacks: { onData }
|
||||
})
|
||||
|
||||
expect(onData).toHaveBeenCalledWith('live before pane\r\n', { rawLength: 18 })
|
||||
transport.disconnect()
|
||||
})
|
||||
|
||||
it('replays pre-handler PTY data before a pre-handler exit', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const events: string[] = []
|
||||
@@ -320,7 +345,12 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const transport = createIpcPtyTransport()
|
||||
const cleanupError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const transport = createIpcPtyTransport({
|
||||
onPtyExit: () => {
|
||||
throw new Error('pre-attach cleanup failed')
|
||||
}
|
||||
})
|
||||
const connectPromise = transport.connect({
|
||||
url: '',
|
||||
callbacks: {
|
||||
@@ -332,8 +362,46 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => {
|
||||
dispatcherCallback?.({ id: 'pty-fast-exit', data: 'last line\r\n', rawLength: 11 })
|
||||
exitDispatcherCallback?.({ id: 'pty-fast-exit', code: 3 })
|
||||
resolveSpawn({ id: 'pty-fast-exit' })
|
||||
await connectPromise
|
||||
const connectResult = await connectPromise
|
||||
|
||||
expect(events).toEqual(['data:last line\r\n', 'exit:3'])
|
||||
expect(connectResult).toEqual({ id: 'pty-fast-exit', exitedBeforeAttach: true })
|
||||
expect(cleanupError).toHaveBeenCalled()
|
||||
|
||||
exitDispatcherCallback?.({ id: 'pty-fast-exit', code: 4 })
|
||||
const duplicateExit = vi.fn()
|
||||
const { drainPreHandlerPtyExit } = await import('./pty-pre-handler-buffer')
|
||||
drainPreHandlerPtyExit('pty-fast-exit', duplicateExit)
|
||||
expect(duplicateExit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finalizes a throwing primary exit and still delivers every sidecar', async () => {
|
||||
const { ensurePtyDispatcher, ptyExitHandlers, subscribeToPtyExit } =
|
||||
await import('./pty-dispatcher')
|
||||
const primary = vi.fn(() => {
|
||||
throw new Error('primary exit failed')
|
||||
})
|
||||
const firstSidecar = vi.fn(() => {
|
||||
throw new Error('sidecar exit failed')
|
||||
})
|
||||
const secondSidecar = vi.fn()
|
||||
|
||||
ensurePtyDispatcher()
|
||||
ptyExitHandlers.set('pty-throwing-exit', primary)
|
||||
subscribeToPtyExit('pty-throwing-exit', firstSidecar)
|
||||
subscribeToPtyExit('pty-throwing-exit', secondSidecar)
|
||||
|
||||
expect(() => exitDispatcherCallback?.({ id: 'pty-throwing-exit', code: 9 })).toThrow(
|
||||
'primary exit failed'
|
||||
)
|
||||
expect(firstSidecar).toHaveBeenCalledWith(9, { hadPrimary: true })
|
||||
expect(secondSidecar).toHaveBeenCalledWith(9, { hadPrimary: true })
|
||||
|
||||
expect(() => exitDispatcherCallback?.({ id: 'pty-throwing-exit', code: 10 })).not.toThrow()
|
||||
expect(primary).toHaveBeenCalledTimes(1)
|
||||
const duplicateExit = vi.fn()
|
||||
const { drainPreHandlerPtyExit } = await import('./pty-pre-handler-buffer')
|
||||
drainPreHandlerPtyExit('pty-throwing-exit', duplicateExit)
|
||||
expect(duplicateExit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp'
|
||||
import {
|
||||
bufferPreHandlerPtyData,
|
||||
bufferPreHandlerPtyExit,
|
||||
clearPreHandlerPtyState,
|
||||
drainPreHandlerPtyData,
|
||||
drainPreHandlerPtyExit
|
||||
} from './pty-pre-handler-buffer'
|
||||
import { deliverPtyExitToHandlers } from './pty-exit-delivery'
|
||||
import {
|
||||
clearReceivedPtyCharTotal,
|
||||
isPtyPushDeliveryBlackholed,
|
||||
@@ -57,7 +57,10 @@ export const ptyDataSidecars = new Map<string, Set<(data: string) => void>>()
|
||||
* guard and suppress xterm auto-replies during replay. */
|
||||
export const ptyReplayHandlers = new Map<string, (data: string) => void>()
|
||||
export const ptyExitHandlers = new Map<string, (code: number) => void>()
|
||||
const ptyExitSidecars = new Map<string, Set<(code: number) => void>>()
|
||||
const ptyExitSidecars = new Map<
|
||||
string,
|
||||
Set<(code: number, context: { hadPrimary: boolean }) => void>
|
||||
>()
|
||||
/** Per-PTY teardown callbacks registered by each transport to clear closure
|
||||
* state (stale-title timer, agent tracker) that would otherwise fire after
|
||||
* the data handler is removed. */
|
||||
@@ -231,21 +234,22 @@ function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void {
|
||||
// cumulative totals too so a reused id restarts at zero on both sides.
|
||||
clearProcessedPtyCharTotal(payload.id)
|
||||
clearReceivedPtyCharTotal(payload.id)
|
||||
const handler = ptyExitHandlers.get(payload.id)
|
||||
if (handler) {
|
||||
clearPreHandlerPtyState(payload.id)
|
||||
handler(payload.code)
|
||||
} else {
|
||||
bufferPreHandlerPtyExit(payload.id, payload.code)
|
||||
}
|
||||
const sidecars = ptyExitSidecars.get(payload.id)
|
||||
if (sidecars && sidecars.size > 0) {
|
||||
const snapshot = Array.from(sidecars)
|
||||
if (sidecars) {
|
||||
ptyExitSidecars.delete(payload.id)
|
||||
for (const sidecar of snapshot) {
|
||||
sidecar(payload.code)
|
||||
}
|
||||
}
|
||||
const primary = ptyExitHandlers.get(payload.id)
|
||||
if (primary) {
|
||||
// Why: exit handlers are one-shot owners. Remove before invocation so
|
||||
// a throwing callback cannot stay registered for a duplicate exit.
|
||||
ptyExitHandlers.delete(payload.id)
|
||||
}
|
||||
deliverPtyExitToHandlers({
|
||||
ptyId: payload.id,
|
||||
code: payload.code,
|
||||
...(primary ? { primary } : {}),
|
||||
sidecars: sidecars ? Array.from(sidecars) : []
|
||||
})
|
||||
})
|
||||
)
|
||||
// Why: main probes when delivery looks stuck on lost ACKs (data arriving
|
||||
@@ -267,7 +271,10 @@ function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void {
|
||||
window.api.pty.rendererDispatcherReady?.()
|
||||
}
|
||||
|
||||
export function subscribeToPtyExit(ptyId: string, watcher: (code: number) => void): () => void {
|
||||
export function subscribeToPtyExit(
|
||||
ptyId: string,
|
||||
watcher: (code: number, context: { hadPrimary: boolean }) => void
|
||||
): () => void {
|
||||
ensurePtyDispatcher()
|
||||
let set = ptyExitSidecars.get(ptyId)
|
||||
if (!set) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
bufferPreHandlerPtyExit,
|
||||
clearPreHandlerPtyState,
|
||||
consumePreHandlerPtyState
|
||||
} from './pty-pre-handler-buffer'
|
||||
|
||||
type PtyExitDelivery = {
|
||||
ptyId: string
|
||||
code: number
|
||||
primary?: (code: number) => void
|
||||
sidecars: readonly ((code: number, context: { hadPrimary: boolean }) => void)[]
|
||||
}
|
||||
|
||||
/** Delivers one exit to its primary owner and every observational sidecar. */
|
||||
export function deliverPtyExitToHandlers(delivery: PtyExitDelivery): void {
|
||||
let firstError: unknown
|
||||
let hasError = false
|
||||
try {
|
||||
if (delivery.primary) {
|
||||
clearPreHandlerPtyState(delivery.ptyId)
|
||||
try {
|
||||
delivery.primary(delivery.code)
|
||||
} finally {
|
||||
// Why: ownership is final even when cleanup throws; a duplicate exit
|
||||
// must not become a new pre-handler event for a future mount.
|
||||
consumePreHandlerPtyState(delivery.ptyId)
|
||||
}
|
||||
} else {
|
||||
bufferPreHandlerPtyExit(delivery.ptyId, delivery.code)
|
||||
}
|
||||
} catch (error) {
|
||||
firstError = error
|
||||
hasError = true
|
||||
}
|
||||
|
||||
for (const sidecar of delivery.sidecars) {
|
||||
try {
|
||||
sidecar(delivery.code, { hadPrimary: delivery.primary !== undefined })
|
||||
} catch (error) {
|
||||
if (!hasError) {
|
||||
firstError = error
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasError) {
|
||||
throw firstError
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
bufferPreHandlerPtyData,
|
||||
bufferPreHandlerPtyExit,
|
||||
clearConsumedPreHandlerPtyExit,
|
||||
clearPreHandlerPtyState,
|
||||
drainPreHandlerPtyData
|
||||
consumePreHandlerPtyState,
|
||||
drainPreHandlerPtyData,
|
||||
drainPreHandlerPtyExit,
|
||||
discardPreHandlerPtyState,
|
||||
hasPreHandlerPtyExit
|
||||
} from './pty-pre-handler-buffer'
|
||||
|
||||
const RESCAN_PTY_ID = 'pty-pre-handler-rescan'
|
||||
const TRIM_PTY_ID = 'pty-pre-handler-trim'
|
||||
const EXIT_PTY_ID = 'pty-pre-handler-exit'
|
||||
const CAPPED_EXIT_PTY_IDS = Array.from({ length: 65 }, (_, index) => `pty-capped-exit-${index}`)
|
||||
|
||||
describe('pre-handler PTY buffer', () => {
|
||||
afterEach(() => {
|
||||
clearPreHandlerPtyState(RESCAN_PTY_ID)
|
||||
clearPreHandlerPtyState(TRIM_PTY_ID)
|
||||
clearPreHandlerPtyState(EXIT_PTY_ID)
|
||||
for (const ptyId of CAPPED_EXIT_PTY_IDS) {
|
||||
clearPreHandlerPtyState(ptyId)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not rescan historical chunks while buffering small startup output', () => {
|
||||
@@ -71,4 +83,91 @@ describe('pre-handler PTY buffer', () => {
|
||||
expect(drained).toHaveLength(512)
|
||||
expect(drained.join('')).toHaveLength(512 * 1_024)
|
||||
})
|
||||
|
||||
it('suppresses duplicate exits after an owner consumes the pre-handler state', () => {
|
||||
const onExit = vi.fn()
|
||||
bufferPreHandlerPtyExit(EXIT_PTY_ID, 1)
|
||||
consumePreHandlerPtyState(EXIT_PTY_ID)
|
||||
bufferPreHandlerPtyExit(EXIT_PTY_ID, 2)
|
||||
|
||||
drainPreHandlerPtyExit(EXIT_PTY_ID, onExit)
|
||||
expect(onExit).not.toHaveBeenCalled()
|
||||
|
||||
bufferPreHandlerPtyData(EXIT_PTY_ID, 'reattach output')
|
||||
clearConsumedPreHandlerPtyExit(EXIT_PTY_ID)
|
||||
const data: string[] = []
|
||||
drainPreHandlerPtyData(EXIT_PTY_ID, (chunk) => data.push(chunk))
|
||||
bufferPreHandlerPtyExit(EXIT_PTY_ID, 3)
|
||||
drainPreHandlerPtyExit(EXIT_PTY_ID, onExit)
|
||||
expect(data).toEqual(['reattach output'])
|
||||
expect(onExit).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('consumes a drained exit even when its handler throws', () => {
|
||||
bufferPreHandlerPtyExit(EXIT_PTY_ID, 7)
|
||||
expect(() =>
|
||||
drainPreHandlerPtyExit(EXIT_PTY_ID, () => {
|
||||
throw new Error('exit handler failed')
|
||||
})
|
||||
).toThrow('exit handler failed')
|
||||
|
||||
bufferPreHandlerPtyExit(EXIT_PTY_ID, 8)
|
||||
const duplicateExit = vi.fn()
|
||||
drainPreHandlerPtyExit(EXIT_PTY_ID, duplicateExit)
|
||||
expect(duplicateExit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports whether an undelivered exit is waiting for admission', () => {
|
||||
expect(hasPreHandlerPtyExit(EXIT_PTY_ID)).toBe(false)
|
||||
bufferPreHandlerPtyExit(EXIT_PTY_ID, 7)
|
||||
expect(hasPreHandlerPtyExit(EXIT_PTY_ID)).toBe(true)
|
||||
drainPreHandlerPtyExit(EXIT_PTY_ID, vi.fn())
|
||||
expect(hasPreHandlerPtyExit(EXIT_PTY_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('discards delayed data and exit until an explicit reconnect', () => {
|
||||
discardPreHandlerPtyState(EXIT_PTY_ID)
|
||||
bufferPreHandlerPtyData(EXIT_PTY_ID, 'late data')
|
||||
bufferPreHandlerPtyExit(EXIT_PTY_ID, 9)
|
||||
const data = vi.fn()
|
||||
const exit = vi.fn()
|
||||
drainPreHandlerPtyData(EXIT_PTY_ID, data)
|
||||
drainPreHandlerPtyExit(EXIT_PTY_ID, exit)
|
||||
expect(data).not.toHaveBeenCalled()
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
|
||||
clearConsumedPreHandlerPtyExit(EXIT_PTY_ID)
|
||||
bufferPreHandlerPtyData(EXIT_PTY_ID, 'new incarnation')
|
||||
drainPreHandlerPtyData(EXIT_PTY_ID, data)
|
||||
expect(data).toHaveBeenCalledWith('new incarnation', undefined)
|
||||
})
|
||||
|
||||
it('retains discard protection beyond the bounded exit-buffer capacity', () => {
|
||||
for (const ptyId of CAPPED_EXIT_PTY_IDS) {
|
||||
discardPreHandlerPtyState(ptyId)
|
||||
}
|
||||
|
||||
bufferPreHandlerPtyData(CAPPED_EXIT_PTY_IDS[0], 'late data')
|
||||
bufferPreHandlerPtyExit(CAPPED_EXIT_PTY_IDS[0], 9)
|
||||
const data = vi.fn()
|
||||
const exit = vi.fn()
|
||||
drainPreHandlerPtyData(CAPPED_EXIT_PTY_IDS[0], data)
|
||||
drainPreHandlerPtyExit(CAPPED_EXIT_PTY_IDS[0], exit)
|
||||
expect(data).not.toHaveBeenCalled()
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('caps exits buffered without a primary handler', () => {
|
||||
for (const [code, ptyId] of CAPPED_EXIT_PTY_IDS.entries()) {
|
||||
bufferPreHandlerPtyExit(ptyId, code)
|
||||
}
|
||||
|
||||
const exits: number[] = []
|
||||
for (const ptyId of CAPPED_EXIT_PTY_IDS) {
|
||||
drainPreHandlerPtyExit(ptyId, (code) => exits.push(code))
|
||||
}
|
||||
expect(exits).toHaveLength(64)
|
||||
expect(exits).not.toContain(0)
|
||||
expect(exits).toContain(64)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,12 +15,16 @@ type BufferedPreHandlerPtyState = {
|
||||
|
||||
const preHandlerPtyData = new Map<string, BufferedPreHandlerPtyState>()
|
||||
const preHandlerPtyExit = new Map<string, number>()
|
||||
const consumedPreHandlerPtyExits = new Map<string, true>()
|
||||
const discardedPreHandlerPtyStates = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const DISCARDED_PRE_HANDLER_PTY_STATE_TTL_MS = 60_000
|
||||
|
||||
// Why: Windows startup commands can emit output before pty:spawn resolves and
|
||||
// the pane registers its handler. Hold that tiny race window instead of ACKing
|
||||
// and dropping the first setup-script bytes.
|
||||
const PRE_HANDLER_PTY_DATA_MAX_BYTES = 512 * 1024
|
||||
const PRE_HANDLER_PTY_DATA_MAX_PTYS = 64
|
||||
const PRE_HANDLER_PTY_EXIT_MAX_PTYS = 64
|
||||
// Why: legit pre-attach windows drain within milliseconds and hold little
|
||||
// data. Sustained accumulation means a pane lost its data handler (the
|
||||
// frozen-pane detach/attach race) — leave a breadcrumb for trace capture.
|
||||
@@ -28,6 +32,9 @@ const PRE_HANDLER_PTY_DATA_WARN_BYTES = 64 * 1024
|
||||
const warnedLostHandlerPtyIds = new Set<string>()
|
||||
|
||||
export function bufferPreHandlerPtyData(ptyId: string, data: string, meta?: PtyDataMeta): void {
|
||||
if (discardedPreHandlerPtyStates.has(ptyId)) {
|
||||
return
|
||||
}
|
||||
const chunk = clampUtf8Tail(data, PRE_HANDLER_PTY_DATA_MAX_BYTES)
|
||||
if (!chunk.data) {
|
||||
return
|
||||
@@ -90,16 +97,83 @@ export function drainPreHandlerPtyData(
|
||||
}
|
||||
|
||||
export function bufferPreHandlerPtyExit(ptyId: string, code: number): void {
|
||||
if (consumedPreHandlerPtyExits.has(ptyId) || discardedPreHandlerPtyStates.has(ptyId)) {
|
||||
return
|
||||
}
|
||||
if (!preHandlerPtyExit.has(ptyId) && preHandlerPtyExit.size >= PRE_HANDLER_PTY_EXIT_MAX_PTYS) {
|
||||
const oldestPtyId = preHandlerPtyExit.keys().next().value
|
||||
if (typeof oldestPtyId === 'string') {
|
||||
preHandlerPtyExit.delete(oldestPtyId)
|
||||
}
|
||||
}
|
||||
preHandlerPtyExit.set(ptyId, code)
|
||||
}
|
||||
|
||||
// Why: primary handlers and pane-less parked owners have fully handled this
|
||||
// exit. Keep a bounded tombstone so duplicate IPC exits cannot be replayed to
|
||||
// a future mount or accumulate in the pre-handler map.
|
||||
export function consumePreHandlerPtyState(ptyId: string): void {
|
||||
clearPreHandlerPtyState(ptyId)
|
||||
consumedPreHandlerPtyExits.set(ptyId, true)
|
||||
if (consumedPreHandlerPtyExits.size > PRE_HANDLER_PTY_EXIT_MAX_PTYS) {
|
||||
const oldestPtyId = consumedPreHandlerPtyExits.keys().next().value
|
||||
if (typeof oldestPtyId === 'string') {
|
||||
consumedPreHandlerPtyExits.delete(oldestPtyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a deliberate reconnect can reuse a live session id after a prior
|
||||
// incarnation's consumed-exit mark. Re-admit exits without discarding bytes
|
||||
// already buffered for the still-live session.
|
||||
export function clearConsumedPreHandlerPtyExit(ptyId: string): void {
|
||||
consumedPreHandlerPtyExits.delete(ptyId)
|
||||
const discardTimer = discardedPreHandlerPtyStates.get(ptyId)
|
||||
if (discardTimer) {
|
||||
clearTimeout(discardTimer)
|
||||
}
|
||||
discardedPreHandlerPtyStates.delete(ptyId)
|
||||
}
|
||||
|
||||
export function isPreHandlerPtyStateDiscarded(ptyId: string): boolean {
|
||||
return discardedPreHandlerPtyStates.has(ptyId)
|
||||
}
|
||||
|
||||
// Why: removed worktrees have no future pane consumer. Suppress both delayed
|
||||
// kill data and exit until an explicit same-id reconnect establishes a new
|
||||
// admission boundary.
|
||||
export function discardPreHandlerPtyState(ptyId: string): void {
|
||||
consumePreHandlerPtyState(ptyId)
|
||||
const priorTimer = discardedPreHandlerPtyStates.get(ptyId)
|
||||
if (priorTimer) {
|
||||
clearTimeout(priorTimer)
|
||||
}
|
||||
// Why: a large worktree can remove more PTYs than the bounded data maps.
|
||||
// Time retention protects every delayed kill flush without permanent growth.
|
||||
const timer = setTimeout(
|
||||
() => discardedPreHandlerPtyStates.delete(ptyId),
|
||||
DISCARDED_PRE_HANDLER_PTY_STATE_TTL_MS
|
||||
)
|
||||
discardedPreHandlerPtyStates.set(ptyId, timer)
|
||||
}
|
||||
|
||||
export function hasPreHandlerPtyExit(ptyId: string): boolean {
|
||||
return preHandlerPtyExit.has(ptyId)
|
||||
}
|
||||
|
||||
export function drainPreHandlerPtyExit(ptyId: string, handler: (code: number) => void): void {
|
||||
const code = preHandlerPtyExit.get(ptyId)
|
||||
if (code === undefined) {
|
||||
return
|
||||
}
|
||||
preHandlerPtyExit.delete(ptyId)
|
||||
handler(code)
|
||||
try {
|
||||
handler(code)
|
||||
} finally {
|
||||
// Why: draining transfers ownership to this handler. Even when it throws,
|
||||
// a duplicate exit must not become a new pre-handler event.
|
||||
consumePreHandlerPtyState(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPreHandlerPtyData(ptyId: string): void {
|
||||
@@ -110,5 +184,11 @@ export function clearPreHandlerPtyData(ptyId: string): void {
|
||||
export function clearPreHandlerPtyState(ptyId: string): void {
|
||||
preHandlerPtyData.delete(ptyId)
|
||||
preHandlerPtyExit.delete(ptyId)
|
||||
consumedPreHandlerPtyExits.delete(ptyId)
|
||||
const discardTimer = discardedPreHandlerPtyStates.get(ptyId)
|
||||
if (discardTimer) {
|
||||
clearTimeout(discardTimer)
|
||||
}
|
||||
discardedPreHandlerPtyStates.delete(ptyId)
|
||||
warnedLostHandlerPtyIds.delete(ptyId)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ export type LocalPtySessionMetadata = {
|
||||
|
||||
export type PtyConnectResult = {
|
||||
id: string
|
||||
/** The requested session exited while it had no primary pane handler. Its
|
||||
* buffered final data/exit were delivered, so callers must not fresh-spawn. */
|
||||
exitedBeforeAttach?: boolean
|
||||
launchAgent?: TuiAgent
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
snapshot?: string
|
||||
|
||||
@@ -342,6 +342,54 @@ describe('createIpcPtyTransport', () => {
|
||||
transport.disconnect()
|
||||
})
|
||||
|
||||
it('mints a fresh id instead of reopening a discarded same-id session', async () => {
|
||||
const { discardPreHandlerPtyState, clearPreHandlerPtyState } =
|
||||
await import('./pty-pre-handler-buffer')
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
const discardedId = 'removed-worktree@@discarded-session'
|
||||
discardPreHandlerPtyState(discardedId)
|
||||
|
||||
await createIpcPtyTransport({ cwdFallback: 'worktree' }).connect({
|
||||
url: '',
|
||||
sessionId: discardedId,
|
||||
callbacks: {}
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwdFallback: 'worktree' }))
|
||||
expect(spawn).toHaveBeenCalledWith(expect.not.objectContaining({ sessionId: discardedId }))
|
||||
clearPreHandlerPtyState(discardedId)
|
||||
})
|
||||
|
||||
it('delivers a buffered dead-session exit without respawning the same session id', async () => {
|
||||
const { bufferPreHandlerPtyData, bufferPreHandlerPtyExit } =
|
||||
await import('./pty-pre-handler-buffer')
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
const onDataCallback = vi.fn()
|
||||
const onExitCallback = vi.fn()
|
||||
const onDisconnect = vi.fn()
|
||||
const onPtyExit = vi.fn()
|
||||
const sessionId = 'dead-parked-session'
|
||||
bufferPreHandlerPtyData(sessionId, 'final output')
|
||||
bufferPreHandlerPtyExit(sessionId, 17)
|
||||
|
||||
const transport = createIpcPtyTransport({ onPtyExit })
|
||||
const result = await transport.connect({
|
||||
url: '',
|
||||
sessionId,
|
||||
callbacks: { onData: onDataCallback, onExit: onExitCallback, onDisconnect }
|
||||
})
|
||||
|
||||
expect(result).toEqual({ id: sessionId, exitedBeforeAttach: true })
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(onDataCallback).toHaveBeenCalledWith('final output')
|
||||
expect(onExitCallback).toHaveBeenCalledWith(17)
|
||||
expect(onDisconnect).toHaveBeenCalledTimes(1)
|
||||
expect(onPtyExit).toHaveBeenCalledWith(sessionId)
|
||||
expect(transport.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns startup cwd fallback metadata to the connection layer', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
@@ -858,7 +906,7 @@ describe('createIpcPtyTransport', () => {
|
||||
onExit?.({ id: 'pty-restored', code: 0 })
|
||||
|
||||
expect(eagerExit).not.toHaveBeenCalled()
|
||||
expect(sidecarExit).toHaveBeenCalledWith(0)
|
||||
expect(sidecarExit).toHaveBeenCalledWith(0, { hadPrimary: true })
|
||||
})
|
||||
|
||||
it('fires onBell for bare BELs but ignores BELs inside OSC sequences', async () => {
|
||||
|
||||
@@ -22,7 +22,13 @@ import {
|
||||
ensurePtyDispatcher,
|
||||
getEagerPtyBufferHandle
|
||||
} from './pty-dispatcher'
|
||||
import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer'
|
||||
import {
|
||||
clearConsumedPreHandlerPtyExit,
|
||||
drainPreHandlerPtyData,
|
||||
drainPreHandlerPtyExit,
|
||||
hasPreHandlerPtyExit,
|
||||
isPreHandlerPtyStateDiscarded
|
||||
} from './pty-pre-handler-buffer'
|
||||
import { createPtyInputWriteQueue } from './pty-input-write-queue'
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types'
|
||||
@@ -649,7 +655,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
}
|
||||
}
|
||||
|
||||
function registerPtyExitHandler(id: string): void {
|
||||
function registerPtyExitHandler(id: string): boolean {
|
||||
const hadBufferedExit = hasPreHandlerPtyExit(id)
|
||||
const exitHandler = (code: number): void => {
|
||||
if (ptyId !== null && ptyId !== id) {
|
||||
// Why: a preserved sleep/reconnect session can report its old exit
|
||||
@@ -674,7 +681,17 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
// would otherwise fire stale notifications after the data handler
|
||||
// is removed but before the exit event arrives.
|
||||
ptyTeardownHandlers.set(id, clearAccumulatedState)
|
||||
drainPreHandlerPtyExit(id, exitHandler)
|
||||
try {
|
||||
drainPreHandlerPtyExit(id, exitHandler)
|
||||
} catch (error) {
|
||||
if (!hadBufferedExit) {
|
||||
throw error
|
||||
}
|
||||
// Why: cleanup callback failures must not turn an already-delivered
|
||||
// pre-attach exit into a generic connect rejection and fallback spawn.
|
||||
console.error('[pty] buffered pre-attach exit cleanup failed', error)
|
||||
}
|
||||
return hadBufferedExit
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -686,12 +703,35 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
return
|
||||
}
|
||||
|
||||
if (options.sessionId && hasPreHandlerPtyExit(options.sessionId)) {
|
||||
// Why: an exited parked session can be recreated under the same id by
|
||||
// createOrAttach. Deliver its buffered final frame/exit before spawn so
|
||||
// the dead incarnation cannot disconnect and orphan a fresh shell.
|
||||
ptyId = options.sessionId
|
||||
connected = true
|
||||
registerPtyDataHandler(options.sessionId)
|
||||
registerPtyExitHandler(options.sessionId)
|
||||
return { id: options.sessionId, exitedBeforeAttach: true } satisfies PtyConnectResult
|
||||
}
|
||||
|
||||
const admittedSessionId =
|
||||
options.sessionId && !isPreHandlerPtyStateDiscarded(options.sessionId)
|
||||
? options.sessionId
|
||||
: undefined
|
||||
|
||||
// Why: reconnecting may intentionally reuse a session id whose prior
|
||||
// exit was consumed. Re-admit exits without clearing bytes already
|
||||
// buffered for a still-live session before its pane registered.
|
||||
if (admittedSessionId) {
|
||||
clearConsumedPreHandlerPtyExit(admittedSessionId)
|
||||
}
|
||||
|
||||
try {
|
||||
// Why: missing-cwd recovery is only valid for fresh local spawns —
|
||||
// reattach must keep the session's exact cwd and SSH-tagged transports
|
||||
// resolve cwd on the remote host.
|
||||
const shouldSendLocalCwdFallback =
|
||||
cwdFallback === 'worktree' && !connectionId && !options.sessionId
|
||||
cwdFallback === 'worktree' && !connectionId && !admittedSessionId
|
||||
const result = await window.api.pty.spawn({
|
||||
cols: options.cols ?? 80,
|
||||
rows: options.rows ?? 24,
|
||||
@@ -712,7 +752,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
? { startupCommandDelivery: options.startupCommandDelivery ?? startupCommandDelivery }
|
||||
: {}),
|
||||
...(connectionId ? { connectionId } : {}),
|
||||
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
||||
...(admittedSessionId ? { sessionId: admittedSessionId } : {}),
|
||||
// Why: hidden-at-spawn mark must land in main before the PTY's
|
||||
// first byte, so it rides the spawn IPC instead of the pane's
|
||||
// first visibility sync (terminal-query-authority.md).
|
||||
@@ -747,7 +787,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
}
|
||||
|
||||
registerPtyDataHandler(spawnResult.id)
|
||||
registerPtyExitHandler(spawnResult.id)
|
||||
const exitedBeforeAttach = registerPtyExitHandler(spawnResult.id)
|
||||
if (exitedBeforeAttach) {
|
||||
return { id: spawnResult.id, exitedBeforeAttach: true } satisfies PtyConnectResult
|
||||
}
|
||||
if (!connected || ptyId !== spawnResult.id) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -28,22 +28,36 @@ vi.mock('./parked-terminal-byte-watcher', () => ({
|
||||
|
||||
type ExitSubscription = {
|
||||
ptyId: string
|
||||
callback: (code: number) => void
|
||||
callback: (code: number, context: { hadPrimary: boolean }) => void
|
||||
unsubscribe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const exitSubscriptions: ExitSubscription[] = []
|
||||
const subscribeToPtyExit = vi.fn((ptyId: string, callback: (code: number) => void) => {
|
||||
const unsubscribe = vi.fn()
|
||||
exitSubscriptions.push({ ptyId, callback, unsubscribe })
|
||||
return unsubscribe
|
||||
})
|
||||
const subscribeToPtyExit = vi.fn(
|
||||
(ptyId: string, callback: (code: number, context: { hadPrimary: boolean }) => void) => {
|
||||
const unsubscribe = vi.fn()
|
||||
exitSubscriptions.push({ ptyId, callback, unsubscribe })
|
||||
return unsubscribe
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('./pty-dispatcher', () => ({
|
||||
subscribeToPtyExit: (ptyId: string, callback: (code: number) => void) =>
|
||||
subscribeToPtyExit(ptyId, callback)
|
||||
}))
|
||||
|
||||
const consumePreHandlerPtyState = vi.fn()
|
||||
vi.mock('./pty-pre-handler-buffer', () => ({
|
||||
discardPreHandlerPtyState: (ptyId: string) => consumePreHandlerPtyState(ptyId)
|
||||
}))
|
||||
|
||||
type CloseTerminalTabOptions = { onClosed?: () => void; onCancel?: () => void }
|
||||
const closeTerminalTab = vi.fn()
|
||||
vi.mock('../terminal/terminal-tab-actions', () => ({
|
||||
closeTerminalTab: (tabId: string, options?: CloseTerminalTabOptions) =>
|
||||
closeTerminalTab(tabId, options)
|
||||
}))
|
||||
|
||||
type MockStoreState = {
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
@@ -224,7 +238,7 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
syncParked()
|
||||
|
||||
const exited = exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)
|
||||
exited?.callback(0)
|
||||
exited?.callback(0, { hadPrimary: false })
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[1].dispose).not.toHaveBeenCalled()
|
||||
@@ -244,16 +258,64 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
expect(startedWatchers[1].options.initialTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops the parked tab entry when the pty-exit sidecar disposes the last watcher', () => {
|
||||
it('consumes the exit and drops the parked entry after the last-watcher close resolves', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false })
|
||||
|
||||
expect(consumePreHandlerPtyState).not.toHaveBeenCalled()
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions
|
||||
options.onClosed?.()
|
||||
|
||||
expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID)
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('retains the buffered exit and empty registry entry when pinned close is cancelled', () => {
|
||||
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
|
||||
options.onCancel?.()
|
||||
syncParked({ parkedTabIds: [] })
|
||||
|
||||
expect(consumePreHandlerPtyState).not.toHaveBeenCalled()
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
|
||||
expect(shouldDeferParkedPtyExitTabClose(TAB_ID, PTY_ID)).toBe(true)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
expect(shouldDeferParkedPtyExitTabClose(TAB_ID, PTY_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('consumes a queued parked exit when another confirmation closes the tab first', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false })
|
||||
|
||||
// Why: a manual pinned close can be ahead of this autonomous exit request
|
||||
// in the confirmation queue and remove the tab before the exit resolves.
|
||||
syncParked({ tabs: [] })
|
||||
|
||||
expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('does not queue a second close when a retained primary handled the parked exit', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: true })
|
||||
|
||||
expect(closeTerminalTab).not.toHaveBeenCalled()
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
@@ -325,6 +387,18 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('consumes parked state when child cleanup observes a removed worktree', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
disposeParkedTerminalWatchersForWorktree(WORKTREE_ID, {
|
||||
consumePreHandlerState: true
|
||||
})
|
||||
|
||||
expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
describe('shouldDeferParkedPtyExitTabClose', () => {
|
||||
const closeTab = vi.fn()
|
||||
|
||||
@@ -397,7 +471,7 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
|
||||
})
|
||||
|
||||
it('collapses the exited leaf via the watcher exit sidecar (no host handler while parked)', () => {
|
||||
it('collapses a dead leaf then closes when the last parked split leaf exits', () => {
|
||||
// Why: hosts' onPtyExit runs from a mounted TerminalPane, so an exit
|
||||
// that lands while parked reaches ONLY the watcher sidecar — it must run
|
||||
// the layout collapse itself or the leaf resurrects on reveal.
|
||||
@@ -418,8 +492,11 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID }
|
||||
}
|
||||
|
||||
exitSubscriptions.find((entry) => entry.ptyId === SECOND_PTY_ID)?.callback(0)
|
||||
exitSubscriptions
|
||||
.find((entry) => entry.ptyId === SECOND_PTY_ID)
|
||||
?.callback(0, { hadPrimary: false })
|
||||
|
||||
expect(consumePreHandlerPtyState).toHaveBeenCalledWith(SECOND_PTY_ID)
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2)
|
||||
expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
@@ -427,6 +504,11 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
})
|
||||
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false })
|
||||
const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions
|
||||
options.onClosed?.()
|
||||
expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID)
|
||||
})
|
||||
|
||||
it('does not touch the layout when the last parked watcher exits (tab-level close owns it)', () => {
|
||||
@@ -439,7 +521,7 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
}
|
||||
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false })
|
||||
|
||||
expect(mockStoreState.setTabLayout).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -453,7 +535,7 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
|
||||
// First leaf dies: deferred, then its exit sidecar drops the watcher.
|
||||
hostOnPtyExit(TAB_ID, PTY_ID)
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false })
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
|
||||
hostOnPtyExit(TAB_ID, SECOND_PTY_ID)
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids'
|
||||
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
|
||||
import { subscribeToPtyExit } from './pty-dispatcher'
|
||||
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
|
||||
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
|
||||
import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking'
|
||||
import {
|
||||
@@ -29,6 +31,8 @@ import {
|
||||
// split exists only to break the store-slice import cycle.
|
||||
export {
|
||||
captureParkedTerminalPaneCandidates,
|
||||
disposeAllParkedTerminalWatchers,
|
||||
disposeRemovedWorktreeParkedTerminalWatchers,
|
||||
disposeParkedTerminalWatchersForPtyIds,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
getParkedTerminalWatcherTabIds,
|
||||
@@ -151,25 +155,47 @@ function startParkedTabWatchers(
|
||||
})
|
||||
// Why: a PTY that exits while parked has no pane to run exit cleanup; at
|
||||
// minimum its watcher must not outlive it.
|
||||
const unsubscribeExit = subscribeToPtyExit(ptyId, () => {
|
||||
const unsubscribeExit = subscribeToPtyExit(ptyId, (_code, { hadPrimary }) => {
|
||||
// Why: while parked this sidecar is the ONLY exit observer — the hosts'
|
||||
// onPtyExit runs from a mounted TerminalPane. Run the observed-exit
|
||||
// teardown's data half here for a multi-leaf tab, or the dead leaf's
|
||||
// stale binding reattaches on reveal and the daemon re-creates the
|
||||
// exited session id as a fresh shell, resurrecting the pane.
|
||||
if (disposersByPtyId.size > 1) {
|
||||
useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId)
|
||||
collapseParkedExitedLeaf(tab.id, ptyId)
|
||||
// teardown here or a sole tab survives, while a dead split leaf can
|
||||
// reattach on reveal and resurrect its exited session as a fresh shell.
|
||||
useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId)
|
||||
if (hadPrimary) {
|
||||
// Why: detach intentionally retains the pane transport's primary exit
|
||||
// owner. It already performs the tab/leaf close; this sidecar only
|
||||
// retires parked observation so one exit cannot queue two confirms.
|
||||
disposersByPtyId.get(ptyId)?.()
|
||||
disposersByPtyId.delete(ptyId)
|
||||
return
|
||||
}
|
||||
if (disposersByPtyId.size > 1) {
|
||||
// Why: this dead split leaf has no future mount. Consume its buffered
|
||||
// frame and exit, and tombstone duplicate exit notifications.
|
||||
discardPreHandlerPtyState(ptyId)
|
||||
collapseParkedExitedLeaf(tab.id, ptyId)
|
||||
disposersByPtyId.get(ptyId)?.()
|
||||
disposersByPtyId.delete(ptyId)
|
||||
return
|
||||
}
|
||||
|
||||
// Why: the close may wait on pinned-tab confirmation. Dispose side
|
||||
// effects now but retain the empty registry entry so parking does not
|
||||
// restart a watcher against the dead PTY while the dialog is pending.
|
||||
disposersByPtyId.get(ptyId)?.()
|
||||
disposersByPtyId.delete(ptyId)
|
||||
// Why: with the last watcher gone there is nothing left to watch or
|
||||
// dispose; dropping the entry keeps the registry bounded to parked
|
||||
// tabs that still hold live PTYs.
|
||||
const entry = parkedWatchersByTabId.get(tab.id)
|
||||
if (disposersByPtyId.size === 0 && entry?.disposersByPtyId === disposersByPtyId) {
|
||||
parkedWatchersByTabId.delete(tab.id)
|
||||
}
|
||||
closeTerminalTab(tab.id, {
|
||||
onClosed: () => {
|
||||
discardPreHandlerPtyState(ptyId)
|
||||
const entry = parkedWatchersByTabId.get(tab.id)
|
||||
if (entry?.disposersByPtyId === disposersByPtyId) {
|
||||
parkedWatchersByTabId.delete(tab.id)
|
||||
}
|
||||
},
|
||||
// Why: cancellation keeps the buffered final frame and exit for the
|
||||
// reveal-mounted pane, matching a pinned terminal that exited mounted.
|
||||
onCancel: () => {}
|
||||
})
|
||||
})
|
||||
paneIdByPtyId.set(ptyId, pane.paneId)
|
||||
disposersByPtyId.set(ptyId, () => {
|
||||
@@ -207,6 +233,13 @@ export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string):
|
||||
}
|
||||
const remaining = entry.disposersByPtyId.size
|
||||
if (remaining === 0) {
|
||||
if (paneId !== undefined) {
|
||||
// Why: an empty entry is the cancelled/pending pinned-close tombstone.
|
||||
// The reveal-mounted pane owns the buffered exit now, so suppress its
|
||||
// close once and drop the registry tombstone.
|
||||
parkedWatchersByTabId.delete(tabId)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Why: this runs from the PTY exit handler, before the exit sidecar above
|
||||
@@ -241,6 +274,18 @@ function collapseParkedExitedLeaf(tabId: string, ptyId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function disposeClosedParkedTabWatchers(
|
||||
tabId: string,
|
||||
entry: { paneIdByPtyId: ReadonlyMap<string, number> }
|
||||
): void {
|
||||
// Why: another queued pinned-close request may close the tab before this
|
||||
// exit request resolves. No pane can drain its retained final frame then.
|
||||
for (const ptyId of entry.paneIdByPtyId.keys()) {
|
||||
discardPreHandlerPtyState(ptyId)
|
||||
}
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles watchers for one worktree against its rendered parked set.
|
||||
* Callers run this from an effect keyed on the committed render state, so
|
||||
@@ -259,7 +304,11 @@ export function syncParkedTerminalTabWatchers(args: {
|
||||
if (entry.worktreeId !== args.worktreeId) {
|
||||
continue
|
||||
}
|
||||
if (!args.parkedTabIds.has(tabId) || !liveTabIds.has(tabId)) {
|
||||
if (!liveTabIds.has(tabId)) {
|
||||
disposeClosedParkedTabWatchers(tabId, entry)
|
||||
continue
|
||||
}
|
||||
if (!args.parkedTabIds.has(tabId) && entry.disposersByPtyId.size > 0) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
bufferPreHandlerPtyData,
|
||||
bufferPreHandlerPtyExit,
|
||||
clearPreHandlerPtyState,
|
||||
drainPreHandlerPtyData,
|
||||
drainPreHandlerPtyExit,
|
||||
hasPreHandlerPtyExit
|
||||
} from './pty-pre-handler-buffer'
|
||||
import {
|
||||
parkedWatchersByTabId,
|
||||
pruneParkedTerminalWatchers
|
||||
} from './terminal-parked-watcher-registry'
|
||||
|
||||
const TAB_ID = 'removed-parked-tab'
|
||||
const PTY_ID = 'removed-worktree@@parked-pty'
|
||||
|
||||
describe('terminal parked watcher registry removal', () => {
|
||||
afterEach(() => {
|
||||
parkedWatchersByTabId.delete(TAB_ID)
|
||||
clearPreHandlerPtyState(PTY_ID)
|
||||
})
|
||||
|
||||
it('consumes retained state and suppresses a delayed exit for a removed worktree', () => {
|
||||
const dispose = vi.fn()
|
||||
parkedWatchersByTabId.set(TAB_ID, {
|
||||
worktreeId: 'removed-worktree',
|
||||
tabPtyId: PTY_ID,
|
||||
paneIdByPtyId: new Map([[PTY_ID, 1]]),
|
||||
disposersByPtyId: new Map([[PTY_ID, dispose]])
|
||||
})
|
||||
bufferPreHandlerPtyData(PTY_ID, 'final frame')
|
||||
bufferPreHandlerPtyExit(PTY_ID, 17)
|
||||
|
||||
pruneParkedTerminalWatchers(new Set())
|
||||
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
expect(parkedWatchersByTabId.has(TAB_ID)).toBe(false)
|
||||
expect(hasPreHandlerPtyExit(PTY_ID)).toBe(false)
|
||||
const data = vi.fn()
|
||||
drainPreHandlerPtyData(PTY_ID, data)
|
||||
expect(data).not.toHaveBeenCalled()
|
||||
|
||||
// Why: the actual kill exit can arrive after sidecar disposal and prune.
|
||||
bufferPreHandlerPtyExit(PTY_ID, 18)
|
||||
bufferPreHandlerPtyData(PTY_ID, 'delayed final frame')
|
||||
const exit = vi.fn()
|
||||
const delayedData = vi.fn()
|
||||
drainPreHandlerPtyExit(PTY_ID, exit)
|
||||
drainPreHandlerPtyData(PTY_ID, delayedData)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
expect(delayedData).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@
|
||||
* mid-evaluation. Keeping the maps and pure disposal here lets the slice
|
||||
* import cycle-free, mirroring how pty-dispatcher exports its handler maps.
|
||||
*/
|
||||
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
|
||||
|
||||
export type ParkedTerminalPaneCapture = {
|
||||
ptyId: string | null
|
||||
@@ -84,19 +85,55 @@ export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeParkedTerminalWatchersForWorktree(worktreeId: string): void {
|
||||
export function disposeParkedTerminalWatchersForWorktree(
|
||||
worktreeId: string,
|
||||
options?: { consumePreHandlerState?: boolean }
|
||||
): void {
|
||||
for (const [tabId, entry] of parkedWatchersByTabId) {
|
||||
if (entry.worktreeId === worktreeId) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
if (options?.consumePreHandlerState) {
|
||||
disposeRemovedWorktreeParkedTabWatchers(tabId, entry)
|
||||
} else {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeRemovedWorktreeParkedTerminalWatchers(
|
||||
worktreeId: string,
|
||||
authoritativePtyIds: readonly string[] = []
|
||||
): void {
|
||||
for (const ptyId of authoritativePtyIds) {
|
||||
discardPreHandlerPtyState(ptyId)
|
||||
}
|
||||
disposeParkedTerminalWatchersForWorktree(worktreeId, { consumePreHandlerState: true })
|
||||
}
|
||||
|
||||
export function disposeAllParkedTerminalWatchers(): void {
|
||||
for (const tabId of Array.from(parkedWatchersByTabId.keys())) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
function disposeRemovedWorktreeParkedTabWatchers(
|
||||
tabId: string,
|
||||
entry: ParkedTabWatcherEntry
|
||||
): void {
|
||||
// Why: removal unregisters sidecars before PTY kill finishes. Tombstone each
|
||||
// old PTY now so its delayed final flush/exit cannot refill bounded buffers
|
||||
// after this worktree loses every future pane consumer.
|
||||
for (const ptyId of entry.paneIdByPtyId.keys()) {
|
||||
discardPreHandlerPtyState(ptyId)
|
||||
}
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
|
||||
/** Drops watchers and captures for worktrees that no longer exist. */
|
||||
export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet<string>): void {
|
||||
for (const [tabId, entry] of parkedWatchersByTabId) {
|
||||
if (!liveWorktreeIds.has(entry.worktreeId)) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
disposeRemovedWorktreeParkedTabWatchers(tabId, entry)
|
||||
}
|
||||
}
|
||||
for (const [tabId, capture] of capturedPanesByTabId) {
|
||||
|
||||
@@ -53,9 +53,9 @@ export function useTerminalTabColdParking(args: {
|
||||
* mounted for their first xterm fit, mirroring the worktree-level guard. */
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
activityTerminalPortals: ActivityTerminalPortalTarget[]
|
||||
/** Tabs the mount restriction keeps unmounted (activation deferral /
|
||||
* targeted background mounts) — parked-equivalent for watcher purposes. */
|
||||
deferredMountTabIds?: ReadonlySet<string>
|
||||
/** Tabs cold activation keeps unmounted — parked-equivalent for watcher
|
||||
* purposes. Targeted background restrictions intentionally stay bounded. */
|
||||
activationDeferredMountTabIds?: ReadonlySet<string> | null
|
||||
}): ReadonlySet<string> {
|
||||
const {
|
||||
worktreeId,
|
||||
@@ -65,7 +65,7 @@ export function useTerminalTabColdParking(args: {
|
||||
coldParkTerminalPanes,
|
||||
shouldMeasureHiddenWorktree,
|
||||
activityTerminalPortals,
|
||||
deferredMountTabIds
|
||||
activationDeferredMountTabIds
|
||||
} = args
|
||||
const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore(
|
||||
@@ -216,12 +216,11 @@ export function useTerminalTabColdParking(args: {
|
||||
) {
|
||||
parked.add(terminalTab.id)
|
||||
}
|
||||
// Why: mount-restricted tabs render no pane regardless of the park
|
||||
// policy (the layer filters them out before parking is consulted), so
|
||||
// the watchers must own their side effects. The measure probe never
|
||||
// mounts them either, hence no shouldMeasureHiddenWorktree gate.
|
||||
// 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 (
|
||||
deferredMountTabIds?.has(terminalTab.id) &&
|
||||
activationDeferredMountTabIds?.has(terminalTab.id) &&
|
||||
!hasActivityTerminalPortal &&
|
||||
canWatcherCoverParkedTerminalTab(worktreeId, terminalTab)
|
||||
) {
|
||||
@@ -234,7 +233,7 @@ export function useTerminalTabColdParking(args: {
|
||||
assignments,
|
||||
coldParkTerminalPanes,
|
||||
coldParkedTerminalTabIds,
|
||||
deferredMountTabIds,
|
||||
activationDeferredMountTabIds,
|
||||
isWorktreeActive,
|
||||
shouldMeasureHiddenWorktree,
|
||||
terminalTabs,
|
||||
@@ -250,11 +249,11 @@ export function useTerminalTabColdParking(args: {
|
||||
worktreeId,
|
||||
tabs: terminalTabs,
|
||||
parkedTabIds: parkedTerminalTabIds,
|
||||
// Why: mount-restricted tabs have no prior pane-owned title slot; pull
|
||||
// main's title-only snapshot when their watcher starts.
|
||||
restoreTitleOnStartTabIds: deferredMountTabIds
|
||||
// Why: activation-deferred tabs have no prior pane-owned title slot;
|
||||
// pull main's title-only snapshot when their watcher starts.
|
||||
restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined
|
||||
})
|
||||
}, [deferredMountTabIds, parkedTerminalTabIds, terminalTabs, worktreeId])
|
||||
}, [activationDeferredMountTabIds, parkedTerminalTabIds, terminalTabs, worktreeId])
|
||||
|
||||
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId])
|
||||
|
||||
|
||||
@@ -145,6 +145,26 @@ describe('pruneClosedBackgroundMountTabs', () => {
|
||||
expect(pruneClosedBackgroundMountTabs(restrictions, mounted, {})).toBe(false)
|
||||
expect(mounted).toEqual(new Set(['wt-visited', 'wt-whole']))
|
||||
})
|
||||
|
||||
it('keeps an activation mounted when its last allowed tab closes before deferred tabs', () => {
|
||||
const restrictions = new Map<string, ReadonlySet<string>>([['wt-1', new Set(['tab-visible'])]])
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>([
|
||||
['wt-1', new Set(['tab-deferred', 'tab-closed'])]
|
||||
])
|
||||
const mounted = new Set(['wt-1'])
|
||||
|
||||
expect(
|
||||
pruneClosedBackgroundMountTabs(
|
||||
restrictions,
|
||||
mounted,
|
||||
{ 'wt-1': [{ id: 'tab-deferred' }] },
|
||||
deferredMountTabIdsByWorktree
|
||||
)
|
||||
).toBe(true)
|
||||
expect(restrictions.get('wt-1')).toEqual(new Set())
|
||||
expect(deferredMountTabIdsByWorktree.get('wt-1')).toEqual(new Set(['tab-deferred']))
|
||||
expect(mounted.has('wt-1')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cold activation tab deferral', () => {
|
||||
@@ -153,8 +173,10 @@ describe('cold activation tab deferral', () => {
|
||||
|
||||
it('mounts everything at once when few tabs would defer', () => {
|
||||
const restrictions = new Map<string, ReadonlySet<string>>([['wt-1', new Set(['tab-1'])]])
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
|
||||
const deferring = planColdActivationTabDeferral({
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
allTabIds: tabIds(COLD_ACTIVATION_TAB_DEFER_THRESHOLD + 1),
|
||||
isTabLive: () => false,
|
||||
@@ -163,12 +185,15 @@ describe('cold activation tab deferral', () => {
|
||||
})
|
||||
expect(deferring).toBe(false)
|
||||
expect(restrictions.has('wt-1')).toBe(false)
|
||||
expect(deferredMountTabIdsByWorktree.has('wt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('restricts a cold activation with many tabs to the immediate set', () => {
|
||||
const restrictions = new Map<string, ReadonlySet<string>>()
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
|
||||
const deferring = planColdActivationTabDeferral({
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
allTabIds: tabIds(10),
|
||||
isTabLive: () => false,
|
||||
@@ -177,12 +202,17 @@ describe('cold activation tab deferral', () => {
|
||||
})
|
||||
expect(deferring).toBe(true)
|
||||
expect(restrictions.get('wt-1')).toEqual(new Set(['tab-3']))
|
||||
expect(deferredMountTabIdsByWorktree.get('wt-1')).toEqual(
|
||||
new Set(['tab-1', 'tab-2', 'tab-4', 'tab-5', 'tab-6', 'tab-7', 'tab-8', 'tab-9', 'tab-10'])
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps live, previously allowed, and non-deferrable tabs mounted', () => {
|
||||
const restrictions = new Map<string, ReadonlySet<string>>([['wt-1', new Set(['tab-2'])]])
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
|
||||
const deferring = planColdActivationTabDeferral({
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
allTabIds: tabIds(12),
|
||||
isTabLive: (tabId) => tabId === 'tab-5',
|
||||
@@ -196,8 +226,10 @@ describe('cold activation tab deferral', () => {
|
||||
|
||||
it('does not defer when most tabs are already live', () => {
|
||||
const restrictions = new Map<string, ReadonlySet<string>>()
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
|
||||
const deferring = planColdActivationTabDeferral({
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
allTabIds: tabIds(10),
|
||||
isTabLive: (tabId) => tabId !== 'tab-10',
|
||||
@@ -211,18 +243,24 @@ describe('cold activation tab deferral', () => {
|
||||
it('reveals newly visible tabs and lifts the restriction once all are revealed', () => {
|
||||
const restrictions = new Map<string, ReadonlySet<string>>([['wt-1', new Set(['tab-1'])]])
|
||||
const allTabIds = tabIds(3)
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>([
|
||||
['wt-1', new Set(['tab-2', 'tab-3'])]
|
||||
])
|
||||
|
||||
revealActivationDeferredTabs({
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
allTabIds,
|
||||
immediateTabIds: new Set(['tab-2'])
|
||||
})
|
||||
expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1', 'tab-2']))
|
||||
expect(deferredMountTabIdsByWorktree.get('wt-1')).toEqual(new Set(['tab-3']))
|
||||
|
||||
const unchanged = restrictions.get('wt-1')
|
||||
revealActivationDeferredTabs({
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
allTabIds,
|
||||
immediateTabIds: new Set(['tab-2'])
|
||||
@@ -231,22 +269,47 @@ describe('cold activation tab deferral', () => {
|
||||
|
||||
revealActivationDeferredTabs({
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
allTabIds,
|
||||
immediateTabIds: new Set(['tab-3'])
|
||||
})
|
||||
expect(restrictions.has('wt-1')).toBe(false)
|
||||
expect(deferredMountTabIdsByWorktree.has('wt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('reveal is a no-op for worktrees without a restriction', () => {
|
||||
const restrictions = new Map<string, ReadonlySet<string>>()
|
||||
it('does not reveal a targeted background restriction as activation deferral', () => {
|
||||
const restrictions = new Map<string, ReadonlySet<string>>([['wt-1', new Set(['tab-1'])]])
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>()
|
||||
revealActivationDeferredTabs({
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
allTabIds: tabIds(3),
|
||||
immediateTabIds: new Set(['tab-1'])
|
||||
immediateTabIds: new Set(['tab-2'])
|
||||
})
|
||||
expect(restrictions.has('wt-1')).toBe(false)
|
||||
expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1']))
|
||||
expect(deferredMountTabIdsByWorktree.has('wt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('hands a targeted wake from activation watcher ownership to its pane', () => {
|
||||
const restrictions = new Map<string, ReadonlySet<string>>([['wt-1', new Set(['tab-1'])]])
|
||||
const deferredMountTabIdsByWorktree = new Map<string, ReadonlySet<string>>([
|
||||
['wt-1', new Set(['tab-2', 'tab-3'])]
|
||||
])
|
||||
const mounted = new Set(['wt-1'])
|
||||
|
||||
applyBackgroundMountTabRestriction(restrictions, mounted, 'wt-1', ['tab-2'])
|
||||
revealActivationDeferredTabs({
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId: 'wt-1',
|
||||
allTabIds: tabIds(3),
|
||||
immediateTabIds: new Set(['tab-2'])
|
||||
})
|
||||
|
||||
expect(restrictions.get('wt-1')).toEqual(new Set(['tab-1', 'tab-2']))
|
||||
expect(deferredMountTabIdsByWorktree.get('wt-1')).toEqual(new Set(['tab-3']))
|
||||
})
|
||||
|
||||
it('collects the tabs a restriction keeps unmounted', () => {
|
||||
|
||||
@@ -141,6 +141,24 @@ export function shouldMountBackgroundWorktreeTab(
|
||||
// until first reveal, parked byte watchers own their side effects meanwhile.
|
||||
export const COLD_ACTIVATION_TAB_DEFER_THRESHOLD = 4
|
||||
|
||||
function replaceActivationDeferredMountTabs(
|
||||
deferredMountTabIdsByWorktree: Map<string, ReadonlySet<string>>,
|
||||
worktreeId: string,
|
||||
restrictedTabIds: ReadonlySet<string> | null,
|
||||
allTabIds: readonly string[]
|
||||
): void {
|
||||
const next = collectDeferredMountTabIds(restrictedTabIds, allTabIds)
|
||||
if (next.size === 0) {
|
||||
deferredMountTabIdsByWorktree.delete(worktreeId)
|
||||
return
|
||||
}
|
||||
const current = deferredMountTabIdsByWorktree.get(worktreeId)
|
||||
if (current?.size === next.size && Array.from(next).every((tabId) => current.has(tabId))) {
|
||||
return
|
||||
}
|
||||
deferredMountTabIdsByWorktree.set(worktreeId, next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether activating `worktreeId` should defer mounting its hidden
|
||||
* terminal tabs until each is first revealed. Installs the restriction (tabs
|
||||
@@ -149,6 +167,7 @@ export const COLD_ACTIVATION_TAB_DEFER_THRESHOLD = 4
|
||||
*/
|
||||
export function planColdActivationTabDeferral(opts: {
|
||||
restrictions: Map<string, ReadonlySet<string>>
|
||||
deferredMountTabIdsByWorktree: Map<string, ReadonlySet<string>>
|
||||
worktreeId: string
|
||||
allTabIds: readonly string[]
|
||||
isTabLive: (tabId: string) => boolean
|
||||
@@ -157,7 +176,15 @@ export function planColdActivationTabDeferral(opts: {
|
||||
isTabDeferrable: (tabId: string) => boolean
|
||||
immediateTabIds: ReadonlySet<string>
|
||||
}): boolean {
|
||||
const { restrictions, worktreeId, allTabIds, isTabLive, isTabDeferrable, immediateTabIds } = opts
|
||||
const {
|
||||
restrictions,
|
||||
deferredMountTabIdsByWorktree,
|
||||
worktreeId,
|
||||
allTabIds,
|
||||
isTabLive,
|
||||
isTabDeferrable,
|
||||
immediateTabIds
|
||||
} = opts
|
||||
const previouslyAllowed = restrictions.get(worktreeId)
|
||||
const initial = new Set<string>()
|
||||
for (const tabId of allTabIds) {
|
||||
@@ -175,9 +202,11 @@ export function planColdActivationTabDeferral(opts: {
|
||||
const deferredCount = allTabIds.length - initial.size
|
||||
if (deferredCount <= COLD_ACTIVATION_TAB_DEFER_THRESHOLD) {
|
||||
restrictions.delete(worktreeId)
|
||||
deferredMountTabIdsByWorktree.delete(worktreeId)
|
||||
return false
|
||||
}
|
||||
restrictions.set(worktreeId, initial)
|
||||
replaceActivationDeferredMountTabs(deferredMountTabIdsByWorktree, worktreeId, initial, allTabIds)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -189,13 +218,21 @@ export function planColdActivationTabDeferral(opts: {
|
||||
*/
|
||||
export function revealActivationDeferredTabs(opts: {
|
||||
restrictions: Map<string, ReadonlySet<string>>
|
||||
deferredMountTabIdsByWorktree: Map<string, ReadonlySet<string>>
|
||||
worktreeId: string
|
||||
allTabIds: readonly string[]
|
||||
immediateTabIds: ReadonlySet<string>
|
||||
}): void {
|
||||
const { restrictions, worktreeId, allTabIds, immediateTabIds } = opts
|
||||
const { restrictions, deferredMountTabIdsByWorktree, worktreeId, allTabIds, immediateTabIds } =
|
||||
opts
|
||||
// Why: targeted background mounts share the allowed-tab restriction map,
|
||||
// but only activation deferral may eagerly fan out parked watcher coverage.
|
||||
if (!deferredMountTabIdsByWorktree.has(worktreeId)) {
|
||||
return
|
||||
}
|
||||
const existing = restrictions.get(worktreeId)
|
||||
if (!existing) {
|
||||
deferredMountTabIdsByWorktree.delete(worktreeId)
|
||||
return
|
||||
}
|
||||
let grew = false
|
||||
@@ -208,11 +245,13 @@ export function revealActivationDeferredTabs(opts: {
|
||||
const next = grew ? new Set([...existing, ...immediateTabIds]) : existing
|
||||
if (allTabIds.length > 0 && allTabIds.every((tabId) => next.has(tabId))) {
|
||||
restrictions.delete(worktreeId)
|
||||
deferredMountTabIdsByWorktree.delete(worktreeId)
|
||||
return
|
||||
}
|
||||
if (grew) {
|
||||
restrictions.set(worktreeId, next)
|
||||
}
|
||||
replaceActivationDeferredMountTabs(deferredMountTabIdsByWorktree, worktreeId, next, allTabIds)
|
||||
}
|
||||
|
||||
/** Tabs a restriction currently keeps unmounted — the set that needs parked
|
||||
@@ -234,25 +273,49 @@ export function collectDeferredMountTabIds(
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases targeted mounts after their owning tabs close. Whole-worktree and
|
||||
* user-visited mounts have no restriction entry and are intentionally retained.
|
||||
* Releases closed tabs from targeted and activation restrictions. Whole-worktree
|
||||
* and fully user-visited mounts have no restriction entry and are retained.
|
||||
*/
|
||||
export function pruneClosedBackgroundMountTabs(
|
||||
restrictions: Map<string, ReadonlySet<string>>,
|
||||
mountedWorktreeIds: Set<string>,
|
||||
tabsByWorktree: Record<string, readonly { id: string }[]>
|
||||
tabsByWorktree: Record<string, readonly { id: string }[]>,
|
||||
deferredMountTabIdsByWorktree?: Map<string, ReadonlySet<string>>
|
||||
): boolean {
|
||||
let changed = false
|
||||
for (const [worktreeId, tabIds] of restrictions) {
|
||||
const liveTabIds = new Set((tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id))
|
||||
const retained = new Set([...tabIds].filter((tabId) => liveTabIds.has(tabId)))
|
||||
const deferred = deferredMountTabIdsByWorktree?.get(worktreeId)
|
||||
const retainedDeferred = deferred
|
||||
? new Set([...deferred].filter((tabId) => liveTabIds.has(tabId)))
|
||||
: null
|
||||
const deferredChanged = deferred !== undefined && retainedDeferred?.size !== deferred.size
|
||||
if (deferredChanged) {
|
||||
changed = true
|
||||
if (retainedDeferred && retainedDeferred.size > 0) {
|
||||
deferredMountTabIdsByWorktree?.set(worktreeId, retainedDeferred)
|
||||
} else {
|
||||
deferredMountTabIdsByWorktree?.delete(worktreeId)
|
||||
// Why: once every deferred tab is gone, the remaining live tabs are
|
||||
// already allowed; release the now-redundant activation restriction.
|
||||
restrictions.delete(worktreeId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (retained.size === tabIds.size) {
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
if (retained.size === 0) {
|
||||
restrictions.delete(worktreeId)
|
||||
mountedWorktreeIds.delete(worktreeId)
|
||||
// Why: an activation restriction may legitimately have no allowed tabs
|
||||
// while live deferred tabs remain; keep the active surface mounted.
|
||||
if (retainedDeferred && retainedDeferred.size > 0) {
|
||||
restrictions.set(worktreeId, retained)
|
||||
} else {
|
||||
restrictions.delete(worktreeId)
|
||||
mountedWorktreeIds.delete(worktreeId)
|
||||
}
|
||||
} else {
|
||||
restrictions.set(worktreeId, retained)
|
||||
}
|
||||
|
||||
@@ -389,6 +389,7 @@ describe('closeTerminalTab', () => {
|
||||
it('closes the pinned tab when the confirmation callback runs', () => {
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
const onClosed = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makePinnedTabState({
|
||||
confirmClosePinnedTab: true,
|
||||
@@ -397,11 +398,35 @@ describe('closeTerminalTab', () => {
|
||||
})
|
||||
)
|
||||
|
||||
closeTerminalTab('pinned-entity-1')
|
||||
closeTerminalTab('pinned-entity-1', { onClosed })
|
||||
expect(onClosed).not.toHaveBeenCalled()
|
||||
const { onConfirm } = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onConfirm: () => void }
|
||||
onConfirm()
|
||||
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-pinned-1')
|
||||
expect(onClosed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports cancellation without finalizing a pinned tab close', () => {
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
const closeUnifiedTab = vi.fn()
|
||||
const onClosed = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
getStateMock.mockReturnValue(
|
||||
makePinnedTabState({
|
||||
confirmClosePinnedTab: true,
|
||||
requestPinnedTabCloseConfirm,
|
||||
closeUnifiedTab
|
||||
})
|
||||
)
|
||||
|
||||
closeTerminalTab('pinned-entity-1', { onClosed, onCancel })
|
||||
const request = requestPinnedTabCloseConfirm.mock.calls[0][0] as { onCancel?: () => void }
|
||||
request.onCancel?.()
|
||||
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(onClosed).not.toHaveBeenCalled()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('guards a pinned tab closed by its unified id (workspace overlay path)', () => {
|
||||
|
||||
@@ -149,10 +149,14 @@ export function createNewTerminalTab(
|
||||
state.setTabBarOrder(activeWorktreeId, order)
|
||||
}
|
||||
|
||||
export function closeTerminalTab(tabId: string, options?: { force?: boolean }): void {
|
||||
export function closeTerminalTab(
|
||||
tabId: string,
|
||||
options?: { force?: boolean; onClosed?: () => void; onCancel?: () => void }
|
||||
): void {
|
||||
const state = useAppStore.getState()
|
||||
const target = resolveCloseTerminalTabTarget(state, tabId)
|
||||
if (!target) {
|
||||
options?.onClosed?.()
|
||||
return
|
||||
}
|
||||
const { worktreeId: owningWorktreeId, terminalTabId } = target
|
||||
@@ -163,7 +167,8 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
|
||||
guardPinnedTabClose({
|
||||
isPinned: true,
|
||||
tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId),
|
||||
onClose: () => closeTerminalTab(tabId, { force: true })
|
||||
onClose: () => closeTerminalTab(tabId, { ...options, force: true }),
|
||||
...(options?.onCancel ? { onCancel: options.onCancel } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -193,6 +198,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
|
||||
tabId: hostBackedTabId,
|
||||
environmentId: runtimeEnvironmentId
|
||||
})
|
||||
options?.onClosed?.()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,6 +223,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
|
||||
}
|
||||
}
|
||||
}
|
||||
options?.onClosed?.()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -230,6 +237,7 @@ export function closeTerminalTab(tabId: string, options?: { force?: boolean }):
|
||||
}
|
||||
|
||||
closeLocalTerminalTabState(terminalTabId)
|
||||
options?.onClosed?.()
|
||||
}
|
||||
|
||||
export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | null): void {
|
||||
|
||||
@@ -89,4 +89,66 @@ describe('createPinnedTabCloseConfirmSlice', () => {
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
|
||||
})
|
||||
|
||||
it('queues concurrent requests and advances them in request order', () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
const store = makeStore()
|
||||
const firstConfirm = vi.fn()
|
||||
const secondConfirm = vi.fn()
|
||||
store.getState().requestPinnedTabCloseConfirm({
|
||||
tabLabel: 'First terminal',
|
||||
onConfirm: firstConfirm
|
||||
})
|
||||
store.getState().requestPinnedTabCloseConfirm({
|
||||
tabLabel: 'Second terminal',
|
||||
onConfirm: secondConfirm
|
||||
})
|
||||
|
||||
expect(store.getState().pinnedTabCloseConfirm?.tabLabel).toBe('First terminal')
|
||||
store.getState().confirmPinnedTabClose()
|
||||
expect(firstConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(secondConfirm).not.toHaveBeenCalled()
|
||||
expect(store.getState().pinnedTabCloseConfirm?.tabLabel).toBe('Second terminal')
|
||||
|
||||
now.mockReturnValue(1_351)
|
||||
store.getState().confirmPinnedTabClose()
|
||||
expect(secondConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().pinnedTabCloseConfirm).toBeNull()
|
||||
})
|
||||
|
||||
it('advances the queue when an earlier request is dismissed', () => {
|
||||
const store = makeStore()
|
||||
const firstCancel = vi.fn()
|
||||
store.getState().requestPinnedTabCloseConfirm({
|
||||
tabLabel: 'First terminal',
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: firstCancel
|
||||
})
|
||||
store.getState().requestPinnedTabCloseConfirm({
|
||||
tabLabel: 'Second terminal',
|
||||
onConfirm: vi.fn()
|
||||
})
|
||||
|
||||
store.getState().dismissPinnedTabClose()
|
||||
|
||||
expect(firstCancel).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().pinnedTabCloseConfirm?.tabLabel).toBe('Second terminal')
|
||||
})
|
||||
|
||||
it('ignores a rapid second action on the newly advanced request', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
const store = makeStore()
|
||||
const secondConfirm = vi.fn()
|
||||
store.getState().requestPinnedTabCloseConfirm({ tabLabel: 'First', onConfirm: vi.fn() })
|
||||
store.getState().requestPinnedTabCloseConfirm({
|
||||
tabLabel: 'Second',
|
||||
onConfirm: secondConfirm
|
||||
})
|
||||
|
||||
store.getState().confirmPinnedTabClose()
|
||||
store.getState().confirmPinnedTabClose()
|
||||
|
||||
expect(secondConfirm).not.toHaveBeenCalled()
|
||||
expect(store.getState().pinnedTabCloseConfirm?.tabLabel).toBe('Second')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,29 +21,60 @@ export const createPinnedTabCloseConfirmSlice: StateCreator<
|
||||
[],
|
||||
[],
|
||||
PinnedTabCloseConfirmSlice
|
||||
> = (set, get) => ({
|
||||
pinnedTabCloseConfirm: null,
|
||||
> = (set, get) => {
|
||||
const queuedRequests: PinnedTabCloseConfirmRequest[] = []
|
||||
let nextRequestActionAllowedAt = 0
|
||||
const INTER_REQUEST_ACTION_GUARD_MS = 350
|
||||
|
||||
requestPinnedTabCloseConfirm: (request) => set({ pinnedTabCloseConfirm: request }),
|
||||
|
||||
confirmPinnedTabClose: () => {
|
||||
const request = get().pinnedTabCloseConfirm
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
// Why: clear before running onConfirm so a re-entrant close path can't see
|
||||
// the stale request and re-open the dialog.
|
||||
set({ pinnedTabCloseConfirm: null })
|
||||
request.onConfirm()
|
||||
},
|
||||
|
||||
dismissPinnedTabClose: () => {
|
||||
const request = get().pinnedTabCloseConfirm
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
// Why: CLI close requests wait for a response even when the user cancels.
|
||||
set({ pinnedTabCloseConfirm: null })
|
||||
request.onCancel?.()
|
||||
const advanceRequest = (): boolean => {
|
||||
const next = queuedRequests.shift() ?? null
|
||||
set({ pinnedTabCloseConfirm: next })
|
||||
return next !== null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
pinnedTabCloseConfirm: null,
|
||||
|
||||
requestPinnedTabCloseConfirm: (request) => {
|
||||
if (get().pinnedTabCloseConfirm) {
|
||||
// Why: autonomous PTY exits can request multiple confirmations in one
|
||||
// tick. Queue them so replacing the visible request cannot strand the
|
||||
// first tab's close cleanup and buffered exit state.
|
||||
queuedRequests.push(request)
|
||||
return
|
||||
}
|
||||
set({ pinnedTabCloseConfirm: request })
|
||||
},
|
||||
|
||||
confirmPinnedTabClose: () => {
|
||||
if (Date.now() < nextRequestActionAllowedAt) {
|
||||
return
|
||||
}
|
||||
const request = get().pinnedTabCloseConfirm
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
// Why: advance before running onConfirm so a re-entrant close queues
|
||||
// behind the next real request instead of seeing the stale one.
|
||||
if (advanceRequest()) {
|
||||
nextRequestActionAllowedAt = Date.now() + INTER_REQUEST_ACTION_GUARD_MS
|
||||
}
|
||||
request.onConfirm()
|
||||
},
|
||||
|
||||
dismissPinnedTabClose: () => {
|
||||
if (Date.now() < nextRequestActionAllowedAt) {
|
||||
return
|
||||
}
|
||||
const request = get().pinnedTabCloseConfirm
|
||||
if (!request) {
|
||||
return
|
||||
}
|
||||
// Why: CLI close requests wait for a response even when the user cancels.
|
||||
if (advanceRequest()) {
|
||||
nextRequestActionAllowedAt = Date.now() + INTER_REQUEST_ACTION_GUARD_MS
|
||||
}
|
||||
request.onCancel?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { findRepoForHost } from './repo-host-identity'
|
||||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { disposeRemovedWorktreeParkedTerminalWatchers } from '../../components/terminal-pane/terminal-parked-watcher-registry'
|
||||
import {
|
||||
callRuntimeRpc,
|
||||
getActiveRuntimeTarget,
|
||||
@@ -3261,6 +3262,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const worktreeBeforeRemoval = get()
|
||||
.allWorktrees()
|
||||
.find((entry) => entry.id === worktreeId)
|
||||
const terminalPtyIdsBeforeRemoval = (get().tabsByWorktree[worktreeId] ?? []).flatMap(
|
||||
(tab) => get().ptyIdsByTabId[tab.id] ?? []
|
||||
)
|
||||
const currentOwner = resolveWorktreeRemovalHost(get(), worktreeId)
|
||||
if (
|
||||
currentOwner.ambiguous ||
|
||||
@@ -3348,6 +3352,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
// batch) rather than only the context menu.
|
||||
requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]')
|
||||
|
||||
// Why: explicit deletion provenance is available here before child
|
||||
// unmount cleanup; identity migration and recoverable remounts are not
|
||||
// destructive and must keep their buffered live PTY state.
|
||||
disposeRemovedWorktreeParkedTerminalWatchers(worktreeId, terminalPtyIdsBeforeRemoval)
|
||||
set((s) => {
|
||||
const next = { ...s.worktreesByRepo }
|
||||
for (const repoId of Object.keys(next)) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { getActiveTabId, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { waitForActiveTerminalManager, waitForPaneIdentitySnapshot } from './helpers/terminal'
|
||||
import {
|
||||
execInTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneIdentitySnapshot
|
||||
} from './helpers/terminal'
|
||||
|
||||
// Why 8: deferral engages only when more than COLD_ACTIVATION_TAB_DEFER_THRESHOLD
|
||||
// (4) tabs would mount cold; 8 tabs with one visible leaves 7 deferred.
|
||||
@@ -47,6 +52,23 @@ async function getMountedTabIds(page: Page, tabIds: readonly string[]): Promise<
|
||||
)
|
||||
}
|
||||
|
||||
async function enableTerminalAccessibilityDom(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((id) => {
|
||||
const manager = window.__paneManagers?.get(id)
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
if (!pane) {
|
||||
throw new Error(`Terminal pane for ${id} is unavailable`)
|
||||
}
|
||||
// Why: xterm paints to canvas by default; screen-reader mode mirrors the
|
||||
// visible buffer into DOM rows so the reveal oracle is user-observable.
|
||||
pane.terminal.options.screenReaderMode = true
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
}, tabId)
|
||||
await expect(
|
||||
page.locator(`[data-terminal-tab-id=${JSON.stringify(tabId)}] .xterm-accessibility-tree`)
|
||||
).toBeAttached({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
test.describe('cold worktree activation deferral', () => {
|
||||
test('mounts only the visible tab cold and reveals deferred tabs on demand', async ({
|
||||
orcaPage: page
|
||||
@@ -95,9 +117,27 @@ test.describe('cold worktree activation deferral', () => {
|
||||
const tabIds = (await getTerminalTabSnapshots(page, worktreeId)).map((tab) => tab.id)
|
||||
const lastActiveTabId = await getActiveTabId(page)
|
||||
|
||||
// Why: workspace-session persistence is debounced; the reload below must
|
||||
// find every tab (and its per-tab layout snapshot) already on disk.
|
||||
await page.waitForTimeout(3_000)
|
||||
// Why: trigger the production synchronous shutdown flush and verify its
|
||||
// persisted result instead of sleeping through the debounce.
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('beforeunload')))
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
async ({ id, expectedTabIds }) => {
|
||||
const session = await window.api.session.get()
|
||||
const persistedTabIds = new Set(
|
||||
(session.tabsByWorktree[id] ?? []).map((tab) => tab.id)
|
||||
)
|
||||
return expectedTabIds.every(
|
||||
(tabId) => persistedTabIds.has(tabId) && session.terminalLayoutsByTabId[tabId]
|
||||
)
|
||||
},
|
||||
{ id: worktreeId, expectedTabIds: tabIds }
|
||||
),
|
||||
{ timeout: 10_000, message: 'terminal tabs and layouts were not persisted before reload' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// Why reload: a fresh renderer against live daemon sessions is the field
|
||||
// scenario — persisted tabs hydrate cold (unregistered, no pending spawn)
|
||||
@@ -120,19 +160,29 @@ test.describe('cold worktree activation deferral', () => {
|
||||
expect(mountedAfterActivation).toContain(lastActiveTabId)
|
||||
}
|
||||
|
||||
// Deferred tabs stay unmounted — there is no timer that mounts them later.
|
||||
await page.waitForTimeout(2_000)
|
||||
const mountedAfterSettle = await getMountedTabIds(page, tabIds)
|
||||
expect(mountedAfterSettle.length).toBe(mountedAfterActivation.length)
|
||||
|
||||
// Why: unmounted tabs must not go silent — the parked byte watchers own
|
||||
// their side effects while deferred.
|
||||
const watcherCoveredTabIds = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & { __terminalParkingDebug?: { parkedTabIds?: () => string[] } }
|
||||
).__terminalParkingDebug?.parkedTabIds?.() ?? []
|
||||
)
|
||||
// their side effects while deferred. This also deterministically proves
|
||||
// the post-commit watcher effect flushed; no settle sleep is needed.
|
||||
let watcherCoveredTabIds: string[] = []
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
watcherCoveredTabIds = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & { __terminalParkingDebug?: { parkedTabIds?: () => string[] } }
|
||||
).__terminalParkingDebug?.parkedTabIds?.() ?? []
|
||||
)
|
||||
const mounted = await getMountedTabIds(page, tabIds)
|
||||
return tabIds.every(
|
||||
(tabId) => mounted.includes(tabId) || watcherCoveredTabIds.includes(tabId)
|
||||
)
|
||||
},
|
||||
{ timeout: 10_000, message: 'deferred tabs did not receive watcher coverage' }
|
||||
)
|
||||
.toBe(true)
|
||||
const mountedAfterSettle = await getMountedTabIds(page, tabIds)
|
||||
expect(mountedAfterSettle).toEqual(mountedAfterActivation)
|
||||
for (const tabId of tabIds) {
|
||||
if (!mountedAfterSettle.includes(tabId)) {
|
||||
expect(watcherCoveredTabIds).toContain(tabId)
|
||||
@@ -142,16 +192,43 @@ test.describe('cold worktree activation deferral', () => {
|
||||
// Revealing a deferred tab mounts it on demand.
|
||||
const deferredTabId = tabIds.find((tabId) => !mountedAfterSettle.includes(tabId))
|
||||
expect(deferredTabId).toBeDefined()
|
||||
if (!deferredTabId) {
|
||||
throw new Error('cold activation left no deferred tab to reveal')
|
||||
}
|
||||
await page.evaluate((tabId) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveTab(tabId)
|
||||
state?.setActiveTabType('terminal')
|
||||
}, deferredTabId as string)
|
||||
}, deferredTabId)
|
||||
await expect
|
||||
.poll(async () => (await getMountedTabIds(page, [deferredTabId as string])).length, {
|
||||
.poll(async () => (await getMountedTabIds(page, [deferredTabId])).length, {
|
||||
timeout: 15_000,
|
||||
message: 'revealed deferred tab did not mount'
|
||||
})
|
||||
.toBe(1)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((tabId) => {
|
||||
const parkedTabIds =
|
||||
(
|
||||
window as Window & { __terminalParkingDebug?: { parkedTabIds?: () => string[] } }
|
||||
).__terminalParkingDebug?.parkedTabIds?.() ?? []
|
||||
return !parkedTabIds.includes(tabId)
|
||||
}, deferredTabId),
|
||||
{ timeout: 10_000, message: 'revealed tab retained its parked watcher' }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const revealedPtyId = await waitForActivePanePtyId(page, 15_000)
|
||||
await enableTerminalAccessibilityDom(page, deferredTabId)
|
||||
const outputMarker = `COLD_DEFER_REVEAL_OK_${Date.now()}`
|
||||
await execInTerminal(page, revealedPtyId, `echo ${outputMarker}`)
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-terminal-tab-id=${JSON.stringify(deferredTabId)}] .xterm-accessibility-tree`
|
||||
)
|
||||
).toContainText(outputMarker, { timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user