diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0d36fa96020..5978bae2353 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -182,6 +182,7 @@ import { } from '../../../../src/session/mobile-file-syntax' import { getTerminalRecordsFromSessionTabs, + hasConnectedTerminalAbsentFromSessionTabs, mergeTerminalListWithKnownRecords, mergeTerminalRecordsByCurrentOrder, mobileSessionTabsEqual, @@ -245,6 +246,11 @@ import { } from '../../../../src/session/mobile-terminal-prune-decision' import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' +import { MobileTerminalInventoryRequest } from '../../../../src/session/mobile-terminal-inventory-request' +import { + useMobileTerminalInventoryRecoveryBridge, + type MobileTerminalInventoryRefreshOptions +} from '../../../../src/session/use-mobile-terminal-inventory-recovery' import { TerminalViewportResubscribeBudget, readTerminalViewportDims, @@ -1277,6 +1283,9 @@ export default function SessionScreen() { ) const unsubscribeTerminalRef = useRef(unsubscribeTerminal) unsubscribeTerminalRef.current = unsubscribeTerminal + const terminalInventoryRecoveryScope = JSON.stringify([hostId, worktreeId]) + const { registerTerminalInventoryRecoveryAction, signalTerminalInventoryRecovery } = + useMobileTerminalInventoryRecoveryBridge(terminalInventoryRecoveryScope) const clearTerminalCache = useCallback(() => { terminalUnsubsRef.current.forEach((unsub) => unsub()) @@ -1379,6 +1388,7 @@ export default function SessionScreen() { diagnostics.firstStreamEvent(handle, seq, data.type) if (data.type === 'end' || data.type === 'error') { unsubscribeTerminalRef.current(handle) + signalTerminalInventoryRecovery() return } if (data.type === 'subscribed') { @@ -1522,7 +1532,10 @@ export default function SessionScreen() { scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200) } }, - () => unsubscribeTerminalRef.current(handle) + () => { + unsubscribeTerminalRef.current(handle) + signalTerminalInventoryRecovery() + } ) if (subscribeSeqRef.current.get(handle) === seq) { @@ -1532,7 +1545,14 @@ export default function SessionScreen() { } subscribingHandlesRef.current.delete(handle) }, - [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast] + [ + client, + getTerminalRef, + markNativeChatInputLeaseReady, + scheduleDelayedAction, + showToast, + signalTerminalInventoryRecovery + ] ) const nativeChatStream = useMobileNativeChatTerminalStream({ @@ -1586,93 +1606,105 @@ export default function SessionScreen() { ) const lastKnownTerminalCountRef = useRef(0) - const fetchTerminalsInFlightRef = useRef(false) + const terminalInventoryRequest = useMemo( + () => new MobileTerminalInventoryRequest(), + [client, hostId, worktreeId] + ) + useEffect(() => { + lastKnownTerminalCountRef.current = 0 + return terminalInventoryRequest.activate() + }, [terminalInventoryRequest]) const fetchTerminals = useCallback( - async (opts: { allowEmptyLoaded?: boolean } = {}) => { + (opts: MobileTerminalInventoryRefreshOptions = {}): Promise => { if (!client) { - return + return Promise.resolve(false) } - if (fetchTerminalsInFlightRef.current) { - return - } - fetchTerminalsInFlightRef.current = true const allowEmptyLoaded = opts.allowEmptyLoaded ?? true - - try { - const response = await client.sendRequest('terminal.list', { - worktree: `id:${worktreeId}`, - includeVisualLayouts: false - }) - if (response.ok) { - const result = (response as RpcSuccess).result as { terminals: Terminal[] } - if (result.terminals.length === 0 && !allowEmptyLoaded) { - return - } - // Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty. - if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) { - lastKnownTerminalCountRef.current = 0 - return - } - - const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) - const pruneContext = { - liveHandles, - showNativeChat: showNativeChatRef.current, - activeHandle: activeHandleRef.current - } - // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. - // Sweep against the retained set, not the raw list: a chat-covered handle - // keeps its subscription across a graph reload, so erasing its live-input - // preference on the same refresh is the erasure this guard exists to stop. - pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext)) - defaultTerminalHandlesToLiveInput([...liveHandles]) - const shouldPrune = createTerminalPrunePredicate(pruneContext) - for (const handle of Array.from(terminalUnsubsRef.current.keys())) { - if (!shouldPrune(handle)) { - continue - } - unsubscribeTerminal(handle) - terminalRefs.current.delete(handle) - initializedHandlesRef.current.delete(handle) - viewportResubscribeBudgetRef.current.forget(handle) - clearTerminalLiveInputDefault(handle) - } - setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune)) - // Why: a chat-covered handle the host reports again refills its rearm budget, - // so an exhausted rearm can't lock the composer until leave-chat. - nativeChatStream.notifyListedHandles(liveHandles) - // Why: same absence-gated refill for the viewport-fit budget — a handle that - // left the list and returned may converge now, so it earns fresh attempts. - viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles) - lastKnownTerminalCountRef.current = result.terminals.length - // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. - const seen = new Set() - const deduped = result.terminals.filter((t) => { - if (seen.has(t.handle)) { + return terminalInventoryRequest.run( + allowEmptyLoaded, + async (allowsEmpty, isCurrent) => { + try { + const response = await client.sendRequest('terminal.list', { + worktree: `id:${worktreeId}`, + includeVisualLayouts: false + }) + if (!isCurrent()) { return false } - seen.add(t.handle) + if (!response.ok) { + return false + } + const result = (response as RpcSuccess).result as { terminals: Terminal[] } + if (result.terminals.length === 0 && !allowsEmpty()) { + return true + } + // Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty. + if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) { + lastKnownTerminalCountRef.current = 0 + return true + } + + const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) + const pruneContext = { + liveHandles, + showNativeChat: showNativeChatRef.current, + activeHandle: activeHandleRef.current + } + // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. + // Sweep against the retained set, not the raw list: a chat-covered handle + // keeps its subscription across a graph reload, so erasing its live-input + // preference on the same refresh is the erasure this guard exists to stop. + pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext)) + defaultTerminalHandlesToLiveInput([...liveHandles]) + const shouldPrune = createTerminalPrunePredicate(pruneContext) + for (const handle of Array.from(terminalUnsubsRef.current.keys())) { + if (!shouldPrune(handle)) { + continue + } + unsubscribeTerminal(handle) + terminalRefs.current.delete(handle) + initializedHandlesRef.current.delete(handle) + viewportResubscribeBudgetRef.current.forget(handle) + clearTerminalLiveInputDefault(handle) + } + setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune)) + // Why: a chat-covered handle the host reports again refills its rearm budget, + // so an exhausted rearm can't lock the composer until leave-chat. + nativeChatStream.notifyListedHandles(liveHandles) + // Why: same absence-gated refill for the viewport-fit budget — a handle that + // left the list and returned may converge now, so it earns fresh attempts. + viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles) + lastKnownTerminalCountRef.current = result.terminals.length + // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. + const seen = new Set() + const deduped = result.terminals.filter((t) => { + if (seen.has(t.handle)) { + return false + } + seen.add(t.handle) + return true + }) + + const mergedTerminals = mergeTerminalListWithKnownRecords( + deduped, + terminalsRef.current, + sessionTabsRef.current + ) + setTerminals((prev) => + terminalRecordsEqual(prev, mergedTerminals) ? prev : mergedTerminals + ) + terminalsRef.current = mergedTerminals + + // Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces. return true - }) - - const mergedTerminals = mergeTerminalListWithKnownRecords( - deduped, - terminalsRef.current, - sessionTabsRef.current - ) - setTerminals((prev) => - terminalRecordsEqual(prev, mergedTerminals) ? prev : mergedTerminals - ) - terminalsRef.current = mergedTerminals - - // Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces. - } - } catch { - // Failed to list terminals - } finally { - fetchTerminalsInFlightRef.current = false - } + } catch { + // Failed to list terminals + return false + } + }, + opts.onPhysicalRequestStarted + ) }, [ client, @@ -1682,6 +1714,7 @@ export default function SessionScreen() { nativeChatStream, pruneTerminalHandlesFromLiveInput, subscribeToTerminal, + terminalInventoryRequest, unsubscribeTerminal ] ) @@ -2302,6 +2335,9 @@ export default function SessionScreen() { () => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null || + // Why: tabs dropped a connected handle we still hold. `terminal.list` is the only + // remover, so keep the fast cadence until that sweep confirms or restores it. + hasConnectedTerminalAbsentFromSessionTabs(terminalsRef.current, sessionTabsRef.current) || // Why: a chat-covered handle that ran out of rearms and left `terminal.list` // was reminted by a desktop graph reload. Only a fresh tab snapshot carries // the replacement handle, so force one instead of holding the composer locked. @@ -2333,7 +2369,8 @@ export default function SessionScreen() { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs, - retryPendingTerminalRecovery + retryPendingTerminalRecovery, + requestTerminalInventoryRecovery } = useMobileSessionTabsReconciliation({ client, connState, @@ -2341,6 +2378,7 @@ export default function SessionScreen() { applySessionTabs, consumeAcceptedSessionTabs, fetchTerminals, + terminalInventoryRecoveryScopeKey: terminalInventoryRecoveryScope, hasRecoveryNeed: hasSessionTabsRecoveryNeed, pendingTerminalRecoveryContextKey, getPendingTerminalRecoveryContextKey, @@ -2349,6 +2387,11 @@ export default function SessionScreen() { ...sessionTabsFetchReporting }) + useEffect( + () => registerTerminalInventoryRecoveryAction(requestTerminalInventoryRecovery), + [registerTerminalInventoryRecoveryAction, requestTerminalInventoryRecovery] + ) + useEffect(() => { if (connState === 'connected') { return diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index 37b1c18971b..7f089b5bcf7 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -9,6 +9,10 @@ const reconciliationHookSource = readFileSync( new URL('./use-mobile-session-tabs-reconciliation.ts', import.meta.url), 'utf8' ) +const terminalInventoryRecoverySource = readFileSync( + new URL('./use-mobile-terminal-inventory-recovery.ts', import.meta.url), + 'utf8' +) const autoCreateHookSource = readFileSync( new URL('./use-initial-session-terminal-autocreate.ts', import.meta.url), 'utf8' @@ -68,25 +72,44 @@ describe('mobile session startup', () => { expect(autoCreateHookSource).toContain('sawSessionTabs: stateRef.current.sawSessionTabs') }) - it('delegates stream ownership while retaining the exact terminal polling cadence', () => { + it('delegates stream ownership while retaining degraded polling and a certified sweep', () => { expect(source).toContain('useMobileSessionTabsReconciliation<') expect(source).toContain('const applicationRevision = ++appliedSessionTabsRevisionRef.current') expect(source).toContain('getApplicationRevision: getSessionTabsApplicationRevision') expect(source).not.toContain("client.subscribe(\n 'session.tabs.subscribe'") expect(reconciliationHookSource).toContain("client.subscribe(\n 'session.tabs.subscribe'") - expect(reconciliationHookSource).toContain( - "if (AppState.currentState !== 'active') {\n controller.setReconciliationActive(false)" - ) - expect(reconciliationHookSource).toContain('void controller.poll()') - expect(reconciliationHookSource).toContain('void fetchTerminals()') + expect(reconciliationHookSource).toContain("if (AppState.currentState !== 'active')") + expect(reconciliationHookSource).toContain('suspendTerminalInventoryRecovery(true)') + expect(reconciliationHookSource).toContain('controller.poll()') + expect(reconciliationHookSource).toContain('tabsRequest !== null') + expect(reconciliationHookSource).toContain('void refreshTerminalInventory()') expect(reconciliationHookSource).toContain("AppState.addEventListener('change'") expect(reconciliationHookSource).toContain('const interval = setInterval(') - expect(reconciliationHookSource).toContain('2000') + expect(reconciliationHookSource).toContain('RECONCILIATION_INTERVAL_MS = 2000') + expect(terminalInventoryRecoverySource).toContain('CERTIFIED_TERMINAL_SWEEP_MS = 60_000') expect(reconciliationHookSource).toContain('controller.setReconciliationActive(false)') expect(reconciliationHookSource).toContain('clearInterval(interval)') expect(reconciliationHookSource).toContain('appStateSubscription.remove()') }) + it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => { + expect(source).toContain("if (data.type === 'end' || data.type === 'error')") + expect(source).toContain('signalTerminalInventoryRecovery()') + expect(terminalInventoryRecoverySource).toContain('actionRef.current = recoveryAction') + expect(terminalInventoryRecoverySource).toContain('pendingSignalScopeRef.current = scopeKey') + expect(terminalInventoryRecoverySource).toContain( + 'committedScope !== null && committedScope !== scopeKey' + ) + expect(source).toContain('return terminalInventoryRequest.activate()') + expect(source).toContain('if (!isCurrent())') + expect(terminalInventoryRecoverySource).toContain( + 'TERMINAL_INVENTORY_CONFIRMATION_DELAY_MS = 750' + ) + expect(terminalInventoryRecoverySource).toContain( + 'refreshTerminalInventory({ allowEmptyLoaded: true })' + ) + }) + it('loads session tabs without waiting for desktop activation', () => { const startupEffect = sliceBetween( 'void (async () => {', diff --git a/mobile/src/session/mobile-terminal-inventory-request.test.ts b/mobile/src/session/mobile-terminal-inventory-request.test.ts new file mode 100644 index 00000000000..a3d0ac926c8 --- /dev/null +++ b/mobile/src/session/mobile-terminal-inventory-request.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileTerminalInventoryRequest } from './mobile-terminal-inventory-request' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +describe('MobileTerminalInventoryRequest', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shares an in-flight request and upgrades its empty-list handling', async () => { + const response = deferred() + const execute = vi.fn(async (allowsEmpty: () => boolean) => { + await response.promise + return allowsEmpty() + }) + const requests = new MobileTerminalInventoryRequest() + const startupStarted = vi.fn() + const recoveryStarted = vi.fn() + vi.spyOn(Date, 'now').mockReturnValueOnce(100).mockReturnValue(200) + + const startup = requests.run(false, execute, startupStarted) + const recovery = requests.run(true, execute, recoveryStarted) + expect(recovery).toBe(startup) + expect(startupStarted).toHaveBeenCalledExactlyOnceWith(100) + expect(recoveryStarted).toHaveBeenCalledExactlyOnceWith(100) + await Promise.resolve() + expect(execute).toHaveBeenCalledTimes(1) + + response.resolve() + await expect(startup).resolves.toBe(true) + await expect(recovery).resolves.toBe(true) + }) + + it('starts fresh after the shared request settles or rejects', async () => { + const requests = new MobileTerminalInventoryRequest() + const failure = new Error('transport lost') + + await expect( + requests.run(true, async () => { + throw failure + }) + ).rejects.toBe(failure) + await expect(requests.run(true, async () => true)).resolves.toBe(true) + }) + + it('fences a response after its route activation is replaced', async () => { + const oldResponse = deferred() + const nextResponse = deferred() + const applied: string[] = [] + const oldRequest = new MobileTerminalInventoryRequest() + const deactivateOld = oldRequest.activate() + const oldResult = oldRequest.run(true, async (_allowsEmpty, isCurrent) => { + await oldResponse.promise + if (!isCurrent()) { + return false + } + applied.push('old') + return true + }) + await Promise.resolve() + + deactivateOld() + const nextRequest = new MobileTerminalInventoryRequest() + nextRequest.activate() + const nextResult = nextRequest.run(true, async (_allowsEmpty, isCurrent) => { + await nextResponse.promise + if (!isCurrent()) { + return false + } + applied.push('next') + return true + }) + await Promise.resolve() + + nextResponse.resolve() + await expect(nextResult).resolves.toBe(true) + oldResponse.resolve() + await expect(oldResult).resolves.toBe(false) + expect(applied).toEqual(['next']) + }) +}) diff --git a/mobile/src/session/mobile-terminal-inventory-request.ts b/mobile/src/session/mobile-terminal-inventory-request.ts new file mode 100644 index 00000000000..8257dd5c0c7 --- /dev/null +++ b/mobile/src/session/mobile-terminal-inventory-request.ts @@ -0,0 +1,52 @@ +type InFlightTerminalInventoryRequest = { + allowEmptyLoaded: boolean + promise: Promise + startedAt: number +} + +export class MobileTerminalInventoryRequest { + private activation: symbol | null = null + private inFlight: InFlightTerminalInventoryRequest | null = null + + activate(): () => void { + const activation = Symbol('terminal-inventory-activation') + this.activation = activation + return () => { + if (this.activation === activation) { + this.activation = null + } + } + } + + run( + allowEmptyLoaded: boolean, + execute: (allowsEmpty: () => boolean, isCurrent: () => boolean) => Promise, + onPhysicalRequestStarted?: (startedAt: number) => void + ): Promise { + if (this.inFlight) { + this.inFlight.allowEmptyLoaded ||= allowEmptyLoaded + onPhysicalRequestStarted?.(this.inFlight.startedAt) + return this.inFlight.promise + } + const activation = this.activation + const request: InFlightTerminalInventoryRequest = { + allowEmptyLoaded, + promise: Promise.resolve(false), + startedAt: Date.now() + } + onPhysicalRequestStarted?.(request.startedAt) + const execution = Promise.resolve().then(() => + execute( + () => request.allowEmptyLoaded, + () => activation !== null && this.activation === activation + ) + ) + request.promise = execution.finally(() => { + if (this.inFlight === request) { + this.inFlight = null + } + }) + this.inFlight = request + return request.promise + } +} diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index cdcbfaafade..0b2b534df93 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { getTerminalRecordsFromSessionTabs, + hasConnectedTerminalAbsentFromSessionTabs, mergeTerminalListWithKnownRecords, mergeTerminalRecordsByCurrentOrder, mobileSessionTabsEqual, @@ -150,4 +151,70 @@ describe('mobile terminal records', () => { ) ).toBe(false) }) + + const record = (over: Partial & { handle: string }): TerminalRecord => ({ + title: 'Terminal', + terminalTheme: undefined, + isActive: false, + ...over + }) + const terminalTab = (handle: string): MobileTerminalSessionTab => ({ + id: `tab-${handle}`, + type: 'terminal', + terminal: handle, + title: 'Terminal', + isActive: false + }) + + it('reports a connected terminal the tab snapshot dropped', () => { + const held = [ + record({ handle: 'pty-1', connected: true }), + record({ handle: 'pty-2', connected: true }) + ] + + expect(hasConnectedTerminalAbsentFromSessionTabs(held, [terminalTab('pty-1')])).toBe(true) + }) + + it('ignores parked handles that tabs never carry', () => { + const parked = [ + record({ handle: 'pty-1', connected: false }), + record({ handle: 'pty-2', connected: false }) + ] + + // A worktree with no live PTY lists every parked leaf while tabs publish none; + // treating that as absence would pin the caller to the fast cadence forever. + expect(hasConnectedTerminalAbsentFromSessionTabs(parked, [])).toBe(false) + }) + + it('ignores orphaned PTYs, which have no leaf and so never appear as a tab', () => { + const orphan = [record({ handle: 'pty-1', connected: true, orphaned: true })] + + expect(hasConnectedTerminalAbsentFromSessionTabs(orphan, [])).toBe(false) + }) + + it('ignores a host that omits connected rather than assuming liveness', () => { + expect(hasConnectedTerminalAbsentFromSessionTabs([record({ handle: 'pty-1' })], [])).toBe(false) + }) + + it('clears once the snapshot covers every connected terminal', () => { + const held = [record({ handle: 'pty-1', connected: true })] + + expect( + hasConnectedTerminalAbsentFromSessionTabs(held, [terminalTab('pty-1'), terminalTab('pty-2')]) + ).toBe(false) + }) + + it('keeps the merge additive so absence only schedules the sweep', () => { + const held = [ + record({ handle: 'pty-1', connected: true }), + record({ handle: 'pty-2', connected: true }) + ] + const tabs = [terminalTab('pty-1')] + + expect( + mergeTerminalRecordsByCurrentOrder(getTerminalRecordsFromSessionTabs(tabs), held).map( + (terminal) => terminal.handle + ) + ).toEqual(['pty-1', 'pty-2']) + }) }) diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index 4a9e4438c84..854513a1a87 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -6,6 +6,10 @@ export type TerminalRecord = { title: string terminalTheme?: MobileTerminalTheme isActive: boolean + /** From `terminal.list`; parked and proven-absent leaves report false. */ + connected?: boolean + /** From `terminal.list`; a live PTY with no leaf, so it never appears as a tab. */ + orphaned?: boolean } export type MobileTerminalSessionTab = { @@ -157,6 +161,26 @@ export function mergeTerminalRecordsByCurrentOrder( ] } +// Why: tab snapshots are partial and can transiently omit a live terminal, so absence +// here is only a hint to schedule the `terminal.list` sweep -- never a reason to prune. +// Restricted to connected, non-orphaned handles: parked leaves and orphaned PTYs are +// legitimately absent from tabs forever and would pin the caller to the fast cadence. +export function hasConnectedTerminalAbsentFromSessionTabs( + currentTerminals: readonly TerminalRecord[], + tabs: readonly MobileSessionTabLike[] +): boolean { + const tabbable = currentTerminals.filter( + (terminal) => terminal.connected === true && terminal.orphaned !== true + ) + if (tabbable.length === 0) { + return false + } + const tabHandles = new Set( + getTerminalRecordsFromSessionTabs(tabs).map((terminal) => terminal.handle) + ) + return tabbable.some((terminal) => !tabHandles.has(terminal.handle)) +} + export function getTerminalRecordsFromSessionTabs( tabs: readonly MobileSessionTabLike[] ): TerminalRecord[] { @@ -169,7 +193,8 @@ export function getTerminalRecordsFromSessionTabs( handle: tab.terminal, title: tab.title || 'Terminal', terminalTheme: tab.terminalTheme, - isActive: tab.isActive === true + isActive: tab.isActive === true, + connected: true } ] }) diff --git a/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts b/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts index fe979521874..3b419994848 100644 --- a/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts +++ b/mobile/src/session/use-mobile-session-tabs-reconciliation.test.ts @@ -1,9 +1,11 @@ -import { createElement } from 'react' +import { createElement, useEffect } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' import type { SessionTabsApplyOutcome } from './mobile-session-tabs-stream-health' import { useMobileSessionTabsReconciliation } from './use-mobile-session-tabs-reconciliation' +import type { MobileTerminalInventoryRefreshOptions } from './use-mobile-terminal-inventory-recovery' const lifecycle = vi.hoisted(() => ({ appState: 'active', @@ -38,7 +40,10 @@ type TestResult = { tabs: string[] } -const fetchTerminals = vi.fn(async () => {}) +const fetchTerminals = vi.fn(async (options?: MobileTerminalInventoryRefreshOptions) => { + options?.onPhysicalRequestStarted?.(Date.now()) + return true +}) const applySessionTabs = vi.fn((value: TestResult): SessionTabsApplyOutcome => ({ accepted: true, effectiveTabs: value.tabs @@ -50,6 +55,9 @@ const hasRecoveryNeed = () => recoveryNeeded const subscribe = vi.fn() const unsubscribe = vi.fn() let streamListener: ((payload: unknown) => void) | null = null +let requestTerminalInventoryRecovery: (() => void) | null = null +let connectionState: ConnectionState = 'connected' +let clientConnectionState: ConnectionState = 'connected' let listSequence = 0 const sendRequest = vi.fn(async () => ({ id: `list-${++listSequence}`, @@ -62,8 +70,17 @@ const sendRequest = vi.fn(async () => ({ })) const client = { sendRequest, - subscribe + subscribe, + getState: () => clientConnectionState } as unknown as RpcClient +const replacementClient = { + sendRequest, + subscribe, + getState: () => clientConnectionState +} as unknown as RpcClient +let currentClient: RpcClient = client +let currentWorktreeId = 'repo::worktree' +let currentTerminalInventoryRecoveryScopeKey = 'host::repo::worktree' function applyWithRecovery(value: TestResult): SessionTabsApplyOutcome { const outcome = applySessionTabs(value) @@ -74,15 +91,24 @@ function applyWithRecovery(value: TestResult): SessionTabsApplyOutcome { } function Harness(): null { - useMobileSessionTabsReconciliation({ - client, - connState: 'connected', - worktreeId: 'repo::worktree', + const actions = useMobileSessionTabsReconciliation({ + client: currentClient, + connState: connectionState, + worktreeId: currentWorktreeId, applySessionTabs: applyWithRecovery, consumeAcceptedSessionTabs, fetchTerminals, + terminalInventoryRecoveryScopeKey: currentTerminalInventoryRecoveryScopeKey, hasRecoveryNeed }) + useEffect(() => { + requestTerminalInventoryRecovery = actions.requestTerminalInventoryRecovery + return () => { + if (requestTerminalInventoryRecovery === actions.requestTerminalInventoryRecovery) { + requestTerminalInventoryRecovery = null + } + } + }, [actions.requestTerminalInventoryRecovery]) return null } @@ -108,6 +134,13 @@ async function setAppState(state: string): Promise { }) } +function expectedRecoveryInventoryOptions() { + return expect.objectContaining({ + allowEmptyLoaded: true, + onPhysicalRequestStarted: expect.any(Function) + }) +} + describe('useMobileSessionTabsReconciliation', () => { let renderer: ReactTestRenderer | null = null async function mount(): Promise { @@ -123,10 +156,19 @@ describe('useMobileSessionTabsReconciliation', () => { lifecycle.appState = 'active' lifecycle.focused = true lifecycle.listeners.clear() + connectionState = 'connected' + clientConnectionState = 'connected' + currentClient = client + currentWorktreeId = 'repo::worktree' + currentTerminalInventoryRecoveryScopeKey = 'host::repo::worktree' recoveryNeeded = false clearRecoveryAt = Number.POSITIVE_INFINITY listSequence = 0 - fetchTerminals.mockClear() + fetchTerminals.mockReset() + fetchTerminals.mockImplementation(async (options?: MobileTerminalInventoryRefreshOptions) => { + options?.onPhysicalRequestStarted?.(Date.now()) + return true + }) applySessionTabs.mockClear() consumeAcceptedSessionTabs.mockClear() unsubscribe.mockClear() @@ -145,10 +187,11 @@ describe('useMobileSessionTabsReconciliation', () => { act(() => renderer?.unmount()) renderer = null streamListener = null + requestTerminalInventoryRecovery = null vi.useRealTimers() }) - it('does zero tab lists and thirty terminal lists in a certified warm minute', async () => { + it('runs one terminal health sweep and zero tab lists in a certified warm minute', async () => { await mount() await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) sendRequest.mockClear() @@ -159,7 +202,275 @@ describe('useMobileSessionTabsReconciliation', () => { }) expect(sendRequest).not.toHaveBeenCalled() - expect(fetchTerminals).toHaveBeenCalledTimes(30) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + }) + + it('backs off a failed certified terminal sweep for another minute', async () => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + fetchTerminals.mockClear() + fetchTerminals.mockResolvedValue(false) + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(58_000) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(2) + }) + + it('coalesces terminal teardown into two separated inventory passes', async () => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + fetchTerminals.mockClear() + + await act(async () => { + requestTerminalInventoryRecovery?.() + requestTerminalInventoryRecovery?.() + requestTerminalInventoryRecovery?.() + await flush() + }) + + expect(fetchTerminals).toHaveBeenCalledExactlyOnceWith(expectedRecoveryInventoryOptions()) + await act(async () => { + await vi.advanceTimersByTimeAsync(749) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(2) + expect(fetchTerminals).toHaveBeenLastCalledWith(expectedRecoveryInventoryOptions()) + }) + + it('moves the certified sweep deadline after teardown recovery', async () => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + fetchTerminals.mockClear() + + await act(async () => { + await vi.advanceTimersByTimeAsync(59_000) + requestTerminalInventoryRecovery?.() + await flush() + await vi.advanceTimersByTimeAsync(1_000) + }) + + expect(fetchTerminals).toHaveBeenCalledTimes(2) + expect(fetchTerminals).toHaveBeenNthCalledWith(1, expectedRecoveryInventoryOptions()) + expect(fetchTerminals).toHaveBeenNthCalledWith(2, expectedRecoveryInventoryOptions()) + }) + + it.each(['failure', 'rejection'] as const)( + 'does not confirm terminal absence after an unverifiable first-pass %s', + async (outcome) => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + fetchTerminals.mockClear() + if (outcome === 'failure') { + fetchTerminals.mockResolvedValueOnce(false) + } else { + fetchTerminals.mockRejectedValueOnce(new Error('transport lost')) + } + + await act(async () => { + requestTerminalInventoryRecovery?.() + await flush() + await vi.advanceTimersByTimeAsync(750) + }) + + expect(fetchTerminals).toHaveBeenCalledExactlyOnceWith(expectedRecoveryInventoryOptions()) + } + ) + + it.each(['background', 'blur', 'disconnect', 'socket-loss', 'unmount'] as const)( + 'cancels terminal inventory confirmation on %s', + async (lifecycleChange) => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + fetchTerminals.mockClear() + await act(async () => { + requestTerminalInventoryRecovery?.() + await flush() + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + if (lifecycleChange === 'background') { + await setAppState('background') + } else if (lifecycleChange === 'blur') { + lifecycle.focused = false + await act(async () => { + renderer?.update(createElement(Harness)) + await flush() + }) + } else if (lifecycleChange === 'disconnect') { + connectionState = 'disconnected' + await act(async () => { + renderer?.update(createElement(Harness)) + await flush() + }) + } else if (lifecycleChange === 'socket-loss') { + clientConnectionState = 'disconnected' + } else { + await act(async () => { + renderer?.unmount() + await flush() + }) + renderer = null + } + + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + } + ) + + it('resumes a pending terminal confirmation after returning to the foreground', async () => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + fetchTerminals.mockClear() + await act(async () => { + requestTerminalInventoryRecovery?.() + await flush() + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + await setAppState('background') + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + await setAppState('active') + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + }) + + expect(fetchTerminals).toHaveBeenCalledTimes(4) + expect(fetchTerminals).toHaveBeenNthCalledWith(3, expectedRecoveryInventoryOptions()) + expect(fetchTerminals).toHaveBeenNthCalledWith(4, expectedRecoveryInventoryOptions()) + }) + + it('resumes pending terminal recovery after a silent socket reconnect', async () => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + fetchTerminals.mockClear() + await act(async () => { + requestTerminalInventoryRecovery?.() + await flush() + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + clientConnectionState = 'disconnected' + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + clientConnectionState = 'connected' + await act(async () => { + await vi.advanceTimersByTimeAsync(1_250) + await vi.advanceTimersByTimeAsync(750) + }) + + expect(fetchTerminals).toHaveBeenCalledTimes(3) + expect(fetchTerminals).toHaveBeenNthCalledWith(2, expectedRecoveryInventoryOptions()) + expect(fetchTerminals).toHaveBeenNthCalledWith(3, expectedRecoveryInventoryOptions()) + }) + + it('resumes a pending terminal confirmation after controller replacement', async () => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + fetchTerminals.mockClear() + await act(async () => { + requestTerminalInventoryRecovery?.() + await flush() + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + currentClient = replacementClient + await act(async () => { + renderer?.update(createElement(Harness)) + await flush() + }) + expect(fetchTerminals).toHaveBeenCalledTimes(3) + + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(4) + expect(fetchTerminals).toHaveBeenLastCalledWith(expectedRecoveryInventoryOptions()) + }) + + it('drops pending terminal confirmation when the route identity changes', async () => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + fetchTerminals.mockClear() + await act(async () => { + requestTerminalInventoryRecovery?.() + await flush() + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + currentWorktreeId = 'repo::other-worktree' + currentTerminalInventoryRecoveryScopeKey = 'host::repo::other-worktree' + await act(async () => { + renderer?.update(createElement(Harness)) + await flush() + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + }) + + expect(fetchTerminals).toHaveBeenCalledTimes(2) + expect(fetchTerminals).toHaveBeenLastCalledWith( + expect.objectContaining({ onPhysicalRequestStarted: expect.any(Function) }) + ) + }) + + it('starts replacement-tab polling after confirmed terminal inventory absence', async () => { + await mount() + await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] }) + sendRequest.mockClear() + fetchTerminals.mockClear() + clearRecoveryAt = 4_000 + let successfulEmptyInventories = 0 + let terminalPruned = false + fetchTerminals.mockImplementation(async () => { + successfulEmptyInventories += 1 + if (successfulEmptyInventories === 2) { + terminalPruned = true + recoveryNeeded = true + } + return true + }) + + await act(async () => { + requestTerminalInventoryRecovery?.() + await flush() + }) + expect(terminalPruned).toBe(false) + + await act(async () => { + await vi.advanceTimersByTimeAsync(750) + }) + expect(successfulEmptyInventories).toBe(2) + expect(terminalPruned).toBe(true) + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_250) + }) + + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(recoveryNeeded).toBe(false) }) it('runs an immediate list plus five fallback lists over ten probing seconds', async () => { @@ -260,7 +571,7 @@ describe('useMobileSessionTabsReconciliation', () => { }) expect(sendRequest).toHaveBeenCalledTimes(5) - expect(fetchTerminals).toHaveBeenCalledTimes(6) + expect(fetchTerminals).toHaveBeenCalledTimes(5) expect(recoveryNeeded).toBe(false) }) diff --git a/mobile/src/session/use-mobile-session-tabs-reconciliation.ts b/mobile/src/session/use-mobile-session-tabs-reconciliation.ts index 277fc77ac47..18858d31026 100644 --- a/mobile/src/session/use-mobile-session-tabs-reconciliation.ts +++ b/mobile/src/session/use-mobile-session-tabs-reconciliation.ts @@ -9,6 +9,10 @@ import { type SessionTabsStreamSource } from './mobile-session-tabs-stream-health' import { PendingTerminalHandleRecoveryBudget } from './pending-terminal-handle-recovery' +import { + useMobileTerminalInventoryRecovery, + type MobileTerminalInventoryRefreshOptions +} from './use-mobile-terminal-inventory-recovery' type Params = { client: RpcClient | null @@ -20,7 +24,8 @@ type Params = { effectiveTabs: readonly Tab[], source: SessionTabsStreamSource ) => void - fetchTerminals: () => Promise + fetchTerminals: (options?: MobileTerminalInventoryRefreshOptions) => Promise + terminalInventoryRecoveryScopeKey: string hasRecoveryNeed: () => boolean pendingTerminalRecoveryContextKey?: string | null getPendingTerminalRecoveryContextKey?: () => string | null @@ -37,9 +42,11 @@ type ResultActions = { ensureSessionTabs: () => Promise fetchPendingBrowserSessionTabs: () => Promise retryPendingTerminalRecovery: () => Promise + requestTerminalInventoryRecovery: () => void } const resolved = Promise.resolve() +const RECONCILIATION_INTERVAL_MS = 2000 export function useMobileSessionTabsReconciliation({ client, @@ -48,6 +55,7 @@ export function useMobileSessionTabsReconciliation({ applySessionTabs, consumeAcceptedSessionTabs, fetchTerminals, + terminalInventoryRecoveryScopeKey, hasRecoveryNeed, pendingTerminalRecoveryContextKey, getPendingTerminalRecoveryContextKey, @@ -122,11 +130,27 @@ export function useMobileSessionTabsReconciliation({ ] ) + const { + activateTerminalInventoryRecovery, + isCertifiedTerminalSweepDue, + refreshTerminalInventory, + requestTerminalInventoryRecovery, + resetCertifiedTerminalSweep, + resumePendingTerminalInventoryRecovery, + suspendTerminalInventoryRecovery + } = useMobileTerminalInventoryRecovery({ + client, + connState, + fetchTerminals, + scopeKey: terminalInventoryRecoveryScopeKey + }) + useEffect( () => () => { + suspendTerminalInventoryRecovery(true) controller?.dispose() }, - [controller] + [controller, suspendTerminalInventoryRecovery] ) useEffect(() => { @@ -149,38 +173,57 @@ export function useMobileSessionTabsReconciliation({ useFocusEffect( useCallback(() => { if (!controller || connState !== 'connected') { + suspendTerminalInventoryRecovery(true) return } + activateTerminalInventoryRecovery() + resetCertifiedTerminalSweep() const refresh = (forceTabs: boolean): void => { if (AppState.currentState !== 'active') { + suspendTerminalInventoryRecovery(true) controller.setReconciliationActive(false) return } + activateTerminalInventoryRecovery() controller.setReconciliationActive(true) - if (forceTabs) { - void controller.requestReconciliation() - } else { - void controller.poll() + const tabsRequest = forceTabs ? controller.requestReconciliation() : controller.poll() + const now = Date.now() + // Why: healthy tab streams own liveness; retain only a slow inventory sweep for stale handles and metadata. + if (forceTabs || tabsRequest !== null || isCertifiedTerminalSweepDue(now)) { + void refreshTerminalInventory() } - void fetchTerminals() + resumePendingTerminalInventoryRecovery() } const appStateSubscription = AppState.addEventListener('change', (state) => { if (state === 'active') { + activateTerminalInventoryRecovery() resetPendingTerminalRecovery() refresh(true) } else { + suspendTerminalInventoryRecovery(true) controller.setReconciliationActive(false) } }) - const interval = setInterval(() => refresh(false), 2000) + const interval = setInterval(() => refresh(false), RECONCILIATION_INTERVAL_MS) resetPendingTerminalRecovery() refresh(true) return () => { + suspendTerminalInventoryRecovery(true) controller.setReconciliationActive(false) clearInterval(interval) appStateSubscription.remove() } - }, [connState, controller, fetchTerminals, resetPendingTerminalRecovery]) + }, [ + activateTerminalInventoryRecovery, + connState, + controller, + isCertifiedTerminalSweepDue, + refreshTerminalInventory, + resetPendingTerminalRecovery, + resetCertifiedTerminalSweep, + resumePendingTerminalInventoryRecovery, + suspendTerminalInventoryRecovery + ]) ) return { @@ -199,6 +242,7 @@ export function useMobileSessionTabsReconciliation({ retryPendingTerminalRecovery: useCallback(() => { resetPendingTerminalRecovery() return controller?.retryReconciliation() ?? resolved - }, [controller, resetPendingTerminalRecovery]) + }, [controller, resetPendingTerminalRecovery]), + requestTerminalInventoryRecovery } } diff --git a/mobile/src/session/use-mobile-terminal-inventory-recovery.test.ts b/mobile/src/session/use-mobile-terminal-inventory-recovery.test.ts new file mode 100644 index 00000000000..83d568b4151 --- /dev/null +++ b/mobile/src/session/use-mobile-terminal-inventory-recovery.test.ts @@ -0,0 +1,322 @@ +import { createElement, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + useMobileTerminalInventoryRecovery, + useMobileTerminalInventoryRecoveryBridge +} from './use-mobile-terminal-inventory-recovery' + +const lifecycle = vi.hoisted(() => ({ appState: 'active' })) + +vi.mock('react-native', () => ({ + AppState: { + get currentState() { + return lifecycle.appState + } + } +})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +const client = { + getState: () => 'connected' +} as unknown as RpcClient +const fetchTerminals = vi.fn(async () => true) +type RecoveryActions = ReturnType +let actions: RecoveryActions | null = null + +function Harness({ scopeKey }: { scopeKey: string }): null { + const recovery = useMobileTerminalInventoryRecovery({ + client, + connState: 'connected', + fetchTerminals, + scopeKey + }) + useEffect(() => { + actions = recovery + return () => { + if (actions === recovery) { + actions = null + } + } + }, [recovery]) + return null +} + +function BridgeHarness({ + scopeKey, + connect, + request +}: { + scopeKey: string + connect: boolean + request: () => void +}): null { + const bridge = useMobileTerminalInventoryRecoveryBridge(scopeKey) + useEffect(() => { + bridgeSignal = bridge.signalTerminalInventoryRecovery + if (!connect) { + return () => { + if (bridgeSignal === bridge.signalTerminalInventoryRecovery) { + bridgeSignal = null + } + } + } + return bridge.registerTerminalInventoryRecoveryAction(request) + }, [bridge, connect, request]) + return null +} + +let bridgeSignal: (() => void) | null = null + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('useMobileTerminalInventoryRecovery', () => { + let renderer: ReactTestRenderer | null = null + let bridgeRenderer: ReactTestRenderer | null = null + + async function mount(scopeKey = 'host::worktree-a'): Promise { + await act(async () => { + renderer = create(createElement(Harness, { scopeKey })) + await flush() + }) + act(() => actions?.activateTerminalInventoryRecovery()) + } + + beforeEach(() => { + vi.useFakeTimers() + lifecycle.appState = 'active' + fetchTerminals.mockReset() + fetchTerminals.mockResolvedValue(true) + bridgeSignal = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + act(() => bridgeRenderer?.unmount()) + renderer = null + bridgeRenderer = null + actions = null + bridgeSignal = null + vi.useRealTimers() + }) + + it('queues a bridge signal until the recovery action connects', async () => { + const request = vi.fn() + await act(async () => { + bridgeRenderer = create( + createElement(BridgeHarness, { scopeKey: 'scope-a', connect: false, request }) + ) + await flush() + }) + + const preConnectSignal = bridgeSignal + expect(preConnectSignal).toEqual(expect.any(Function)) + act(() => preConnectSignal?.()) + expect(request).not.toHaveBeenCalled() + + await act(async () => { + bridgeRenderer?.update( + createElement(BridgeHarness, { scopeKey: 'scope-a', connect: true, request }) + ) + await flush() + }) + expect(request).toHaveBeenCalledExactlyOnceWith() + }) + + it('ignores a stale bridge signal after the committed scope changes', async () => { + const requestA = vi.fn() + const requestB = vi.fn() + await act(async () => { + bridgeRenderer = create( + createElement(BridgeHarness, { scopeKey: 'scope-a', connect: true, request: requestA }) + ) + await flush() + }) + const staleSignal = bridgeSignal + + await act(async () => { + bridgeRenderer?.update( + createElement(BridgeHarness, { scopeKey: 'scope-b', connect: true, request: requestB }) + ) + await flush() + }) + act(() => staleSignal?.()) + expect(requestA).not.toHaveBeenCalled() + expect(requestB).not.toHaveBeenCalled() + + act(() => bridgeSignal?.()) + expect(requestB).toHaveBeenCalledExactlyOnceWith() + }) + + it('coalesces signals before confirmation into the scheduled pass', async () => { + const firstPass = deferred() + const confirmation = deferred() + fetchTerminals + .mockImplementationOnce(() => firstPass.promise) + .mockImplementationOnce(() => confirmation.promise) + await mount() + + act(() => { + actions?.requestTerminalInventoryRecovery() + actions?.requestTerminalInventoryRecovery() + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + await act(async () => { + firstPass.resolve(true) + await flush() + actions?.requestTerminalInventoryRecovery() + await vi.advanceTimersByTimeAsync(750) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(2) + + await act(async () => { + confirmation.resolve(true) + await flush() + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(2) + }) + + it.each(['failure', 'rejection'] as const)( + 'retries one queued signal after a first-pass %s', + async (outcome) => { + const firstPass = deferred() + fetchTerminals.mockImplementationOnce(() => firstPass.promise).mockResolvedValueOnce(false) + await mount() + + act(() => { + actions?.requestTerminalInventoryRecovery() + actions?.requestTerminalInventoryRecovery() + }) + await act(async () => { + if (outcome === 'failure') { + firstPass.resolve(false) + } else { + firstPass.reject(new Error('transport lost')) + } + await flush() + }) + + expect(fetchTerminals).toHaveBeenCalledTimes(2) + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(2) + } + ) + + it('runs one follow-up cycle for signals received during confirmation', async () => { + const confirmation = deferred() + fetchTerminals + .mockResolvedValueOnce(true) + .mockImplementationOnce(() => confirmation.promise) + .mockResolvedValueOnce(false) + await mount() + + act(() => actions?.requestTerminalInventoryRecovery()) + await act(async () => { + await flush() + await vi.advanceTimersByTimeAsync(750) + actions?.requestTerminalInventoryRecovery() + actions?.requestTerminalInventoryRecovery() + confirmation.resolve(false) + await flush() + }) + + expect(fetchTerminals).toHaveBeenCalledTimes(3) + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000) + }) + expect(fetchTerminals).toHaveBeenCalledTimes(3) + }) + + it('retains one cycle across lifecycle suspension and fences the old pass', async () => { + const oldPass = deferred() + fetchTerminals.mockImplementationOnce(() => oldPass.promise).mockResolvedValueOnce(false) + await mount() + + act(() => { + actions?.requestTerminalInventoryRecovery() + actions?.suspendTerminalInventoryRecovery(true) + }) + await act(async () => { + oldPass.resolve(false) + await flush() + }) + expect(fetchTerminals).toHaveBeenCalledTimes(1) + + act(() => { + actions?.activateTerminalInventoryRecovery() + actions?.resumePendingTerminalInventoryRecovery() + }) + await act(flush) + expect(fetchTerminals).toHaveBeenCalledTimes(2) + }) + + it('drops queued work when the recovery scope changes', async () => { + const oldPass = deferred() + fetchTerminals.mockImplementationOnce(() => oldPass.promise) + await mount() + + act(() => { + actions?.requestTerminalInventoryRecovery() + actions?.requestTerminalInventoryRecovery() + }) + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'host::worktree-b' })) + }) + await act(async () => { + oldPass.resolve(false) + await flush() + }) + act(() => { + actions?.activateTerminalInventoryRecovery() + actions?.resumePendingTerminalInventoryRecovery() + }) + + expect(fetchTerminals).toHaveBeenCalledTimes(1) + }) + + it('does not let an old inventory completion move the new scope deadline', async () => { + const oldPass = deferred() + let reportPhysicalStart: ((startedAt: number) => void) | undefined + fetchTerminals.mockImplementationOnce(async (options) => { + reportPhysicalStart = options?.onPhysicalRequestStarted + return oldPass.promise + }) + await mount('scope-a') + vi.setSystemTime(100) + const oldRefresh = actions?.refreshTerminalInventory + expect(oldRefresh).toEqual(expect.any(Function)) + const oldRequest = oldRefresh!() + await flush() + reportPhysicalStart?.(100) + expect(actions?.isCertifiedTerminalSweepDue(100)).toBe(false) + + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'scope-b' })) + await flush() + }) + expect(actions?.isCertifiedTerminalSweepDue(100)).toBe(true) + + reportPhysicalStart?.(100) + oldPass.resolve(true) + await oldRequest + await flush() + expect(actions?.isCertifiedTerminalSweepDue(100)).toBe(true) + }) +}) diff --git a/mobile/src/session/use-mobile-terminal-inventory-recovery.ts b/mobile/src/session/use-mobile-terminal-inventory-recovery.ts new file mode 100644 index 00000000000..fe56d68a819 --- /dev/null +++ b/mobile/src/session/use-mobile-terminal-inventory-recovery.ts @@ -0,0 +1,248 @@ +import { useCallback, useEffect, useRef } from 'react' +import { AppState } from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' + +export type MobileTerminalInventoryRefreshOptions = { + allowEmptyLoaded?: boolean + onPhysicalRequestStarted?: (startedAt: number) => void +} + +type MobileTerminalInventoryRecoveryAction = { + request: () => void + scope: string +} + +/** Bridges terminal stream callbacks to the committed route's recovery action. */ +export function useMobileTerminalInventoryRecoveryBridge(scopeKey: string) { + const committedScopeRef = useRef(null) + const actionRef = useRef(null) + const pendingSignalScopeRef = useRef(null) + + useEffect(() => { + committedScopeRef.current = scopeKey + return () => { + if (committedScopeRef.current === scopeKey) { + committedScopeRef.current = null + } + } + }, [scopeKey]) + + const signalTerminalInventoryRecovery = useCallback(() => { + const committedScope = committedScopeRef.current + if (committedScope !== null && committedScope !== scopeKey) { + return + } + const recoveryAction = actionRef.current + if (recoveryAction?.scope === scopeKey) { + recoveryAction.request() + return + } + pendingSignalScopeRef.current = scopeKey + }, [scopeKey]) + + const registerTerminalInventoryRecoveryAction = useCallback( + (request: () => void): (() => void) => { + const recoveryAction = { request, scope: scopeKey } + actionRef.current = recoveryAction + const pendingScope = pendingSignalScopeRef.current + pendingSignalScopeRef.current = null + if (pendingScope === scopeKey) { + request() + } + return () => { + if (actionRef.current === recoveryAction) { + actionRef.current = null + } + } + }, + [scopeKey] + ) + + return { + registerTerminalInventoryRecoveryAction, + signalTerminalInventoryRecovery + } +} + +type Params = { + client: RpcClient | null + connState: ConnectionState + fetchTerminals: (options?: MobileTerminalInventoryRefreshOptions) => Promise + scopeKey: string +} + +type RecoveryState = { + active: boolean + generation: number + pending: boolean + phase: 'idle' | 'first-pass' | 'confirmation-wait' | 'confirmation-pass' + timer: ReturnType | null +} + +const CERTIFIED_TERMINAL_SWEEP_MS = 60_000 +const TERMINAL_INVENTORY_CONFIRMATION_DELAY_MS = 750 + +export function useMobileTerminalInventoryRecovery({ + client, + connState, + fetchTerminals, + scopeKey +}: Params) { + const stateRef = useRef({ + active: false, + generation: 0, + pending: false, + phase: 'idle', + timer: null + }) + const lastAttemptAtRef = useRef(Number.NEGATIVE_INFINITY) + const refreshTerminalInventory = useCallback( + async (options?: MobileTerminalInventoryRefreshOptions): Promise => { + const logicalStartedAt = Date.now() + const generation = stateRef.current.generation + let physicalStartReported = false + try { + return await fetchTerminals({ + ...options, + onPhysicalRequestStarted: (startedAt) => { + physicalStartReported = true + if (stateRef.current.generation === generation) { + lastAttemptAtRef.current = startedAt + } + } + }) + } finally { + if (!physicalStartReported && stateRef.current.generation === generation) { + lastAttemptAtRef.current = logicalStartedAt + } + } + }, + [fetchTerminals] + ) + const isCertifiedTerminalSweepDue = useCallback((now: number): boolean => { + const elapsed = now - lastAttemptAtRef.current + return elapsed < 0 || elapsed >= CERTIFIED_TERMINAL_SWEEP_MS + }, []) + const resetCertifiedTerminalSweep = useCallback(() => { + lastAttemptAtRef.current = Number.NEGATIVE_INFINITY + }, []) + const canRun = useCallback( + () => + stateRef.current.active && + AppState.currentState === 'active' && + connState === 'connected' && + (client?.getState?.() ?? connState) === 'connected', + [client, connState] + ) + const suspendTerminalInventoryRecovery = useCallback((retainPending: boolean): void => { + const state = stateRef.current + state.active = false + state.generation += 1 + state.pending = retainPending && (state.pending || state.phase !== 'idle') + state.phase = 'idle' + if (state.timer !== null) { + clearTimeout(state.timer) + state.timer = null + } + }, []) + const activateTerminalInventoryRecovery = useCallback(() => { + stateRef.current.active = true + }, []) + const requestTerminalInventoryRecovery = useCallback((): void => { + const state = stateRef.current + if (!canRun()) { + state.pending = true + return + } + if (state.phase === 'first-pass' || state.phase === 'confirmation-pass') { + state.pending = true + return + } + if (state.phase === 'confirmation-wait') { + return + } + + const startCycle = (): void => { + state.pending = false + state.phase = 'first-pass' + const generation = state.generation + const finishPass = (): void => { + state.phase = 'idle' + if (state.pending && canRun()) { + startCycle() + } + } + + void (async () => { + let firstPassSucceeded = false + try { + firstPassSucceeded = await refreshTerminalInventory({ allowEmptyLoaded: true }) + } catch { + // A failed inventory is unverifiable. + } + if (generation !== state.generation) { + return + } + if (!firstPassSucceeded) { + finishPass() + return + } + // The confirmation pass also satisfies signals received during the first pass. + state.pending = false + if (!canRun()) { + state.pending = true + state.phase = 'idle' + return + } + state.phase = 'confirmation-wait' + state.timer = setTimeout(() => { + state.timer = null + if (generation !== state.generation) { + return + } + if (!canRun()) { + state.pending = true + state.phase = 'idle' + return + } + state.phase = 'confirmation-pass' + void refreshTerminalInventory({ allowEmptyLoaded: true }) + .catch(() => { + // A transport failure cannot confirm terminal absence. + }) + .finally(() => { + if (generation === state.generation) { + finishPass() + } + }) + }, TERMINAL_INVENTORY_CONFIRMATION_DELAY_MS) + })() + } + + startCycle() + }, [canRun, refreshTerminalInventory]) + const resumePendingTerminalInventoryRecovery = useCallback(() => { + if (stateRef.current.pending) { + requestTerminalInventoryRecovery() + } + }, [requestTerminalInventoryRecovery]) + + useEffect( + () => () => { + suspendTerminalInventoryRecovery(false) + lastAttemptAtRef.current = Number.NEGATIVE_INFINITY + }, + [scopeKey, suspendTerminalInventoryRecovery] + ) + + return { + activateTerminalInventoryRecovery, + isCertifiedTerminalSweepDue, + refreshTerminalInventory, + requestTerminalInventoryRecovery, + resetCertifiedTerminalSweep, + resumePendingTerminalInventoryRecovery, + suspendTerminalInventoryRecovery + } +}