mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user