perf(mobile): slow certified terminal inventory sweeps (#17182)

* perf(mobile): slow certified terminal inventory sweeps

* fix(mobile): recover terminal inventory after stream teardown

* fix(mobile): keep fast sweeps while tabs drop a connected terminal

Tab snapshots are partial and only ever add terminals, so `terminal.list`
is the sole remover. With healthy sweeps slowed to 1/min, a background
terminal closed on the desktop lingered up to 60s, leaking its WebView and
leaving tabStripVisible stale.

Carry `connected`/`orphaned` through TerminalRecord and treat absence of a
connected, non-orphaned handle as a hint to schedule the authority -- never
as a decision to prune. Parked leaves and orphaned PTYs are legitimately
untabbed forever, so excluding them keeps the slow cadence from pinning.
This commit is contained in:
Neil
2026-08-29 21:18:28 -07:00
committed by GitHub
parent 2096b7a2e1
commit 8cf692cf71
10 changed files with 1336 additions and 111 deletions
+125 -82
View File
@@ -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<boolean> => {
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<string>()
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<string>()
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<SessionTabsResult, MobileSessionTab>({
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
@@ -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 () => {',
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileTerminalInventoryRequest } from './mobile-terminal-inventory-request'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<void>()
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<void>()
const nextResponse = deferred<void>()
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'])
})
})
@@ -0,0 +1,52 @@
type InFlightTerminalInventoryRequest = {
allowEmptyLoaded: boolean
promise: Promise<boolean>
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<boolean>,
onPhysicalRequestStarted?: (startedAt: number) => void
): Promise<boolean> {
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
}
}
@@ -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<TerminalRecord> & { 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'])
})
})
+26 -1
View File
@@ -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
}
]
})
@@ -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<string> => ({
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<string> {
const outcome = applySessionTabs(value)
@@ -74,15 +91,24 @@ function applyWithRecovery(value: TestResult): SessionTabsApplyOutcome<string> {
}
function Harness(): null {
useMobileSessionTabsReconciliation<TestResult, string>({
client,
connState: 'connected',
worktreeId: 'repo::worktree',
const actions = useMobileSessionTabsReconciliation<TestResult, string>({
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<void> {
})
}
function expectedRecoveryInventoryOptions() {
return expect.objectContaining({
allowEmptyLoaded: true,
onPhysicalRequestStarted: expect.any(Function)
})
}
describe('useMobileSessionTabsReconciliation', () => {
let renderer: ReactTestRenderer | null = null
async function mount(): Promise<void> {
@@ -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)
})
@@ -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<Result, Tab> = {
client: RpcClient | null
@@ -20,7 +24,8 @@ type Params<Result, Tab> = {
effectiveTabs: readonly Tab[],
source: SessionTabsStreamSource
) => void
fetchTerminals: () => Promise<void>
fetchTerminals: (options?: MobileTerminalInventoryRefreshOptions) => Promise<boolean>
terminalInventoryRecoveryScopeKey: string
hasRecoveryNeed: () => boolean
pendingTerminalRecoveryContextKey?: string | null
getPendingTerminalRecoveryContextKey?: () => string | null
@@ -37,9 +42,11 @@ type ResultActions = {
ensureSessionTabs: () => Promise<void>
fetchPendingBrowserSessionTabs: () => Promise<void>
retryPendingTerminalRecovery: () => Promise<void>
requestTerminalInventoryRecovery: () => void
}
const resolved = Promise.resolve()
const RECONCILIATION_INTERVAL_MS = 2000
export function useMobileSessionTabsReconciliation<Result, Tab>({
client,
@@ -48,6 +55,7 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
applySessionTabs,
consumeAcceptedSessionTabs,
fetchTerminals,
terminalInventoryRecoveryScopeKey,
hasRecoveryNeed,
pendingTerminalRecoveryContextKey,
getPendingTerminalRecoveryContextKey,
@@ -122,11 +130,27 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
]
)
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<Result, Tab>({
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<Result, Tab>({
retryPendingTerminalRecovery: useCallback(() => {
resetPendingTerminalRecovery()
return controller?.retryReconciliation() ?? resolved
}, [controller, resetPendingTerminalRecovery])
}, [controller, resetPendingTerminalRecovery]),
requestTerminalInventoryRecovery
}
}
@@ -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<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<typeof useMobileTerminalInventoryRecovery>
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<void> {
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<void> {
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<boolean>()
const confirmation = deferred<boolean>()
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<boolean>()
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<boolean>()
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<boolean>()
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<boolean>()
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<boolean>()
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)
})
})
@@ -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<string | null>(null)
const actionRef = useRef<MobileTerminalInventoryRecoveryAction | null>(null)
const pendingSignalScopeRef = useRef<string | null>(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<boolean>
scopeKey: string
}
type RecoveryState = {
active: boolean
generation: number
pending: boolean
phase: 'idle' | 'first-pass' | 'confirmation-wait' | 'confirmation-pass'
timer: ReturnType<typeof setTimeout> | 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<RecoveryState>({
active: false,
generation: 0,
pending: false,
phase: 'idle',
timer: null
})
const lastAttemptAtRef = useRef(Number.NEGATIVE_INFINITY)
const refreshTerminalInventory = useCallback(
async (options?: MobileTerminalInventoryRefreshOptions): Promise<boolean> => {
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
}
}