fix(mobile): harden reconnect verification edges

This commit is contained in:
Brennan Benson
2026-07-31 12:40:40 -07:00
parent 911e938025
commit 6637c8d179
22 changed files with 149 additions and 35 deletions
+2 -1
View File
@@ -18,6 +18,7 @@ import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/hos
import { displayHostEndpoint } from '../../../src/transport/host-endpoint'
import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit'
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
import { showForceReconnectError } from '../../../src/transport/force-reconnect-feedback'
import type { HostProfile } from '../../../src/transport/types'
export default function EditHostScreen() {
@@ -137,7 +138,7 @@ export default function EditHostScreen() {
// Why: reconnect is a follow-on side effect of a save that already
// committed — its failure or a hang must not be reported as a save
// failure or block navigating back.
void forceReconnectHost(host.id).catch(() => {})
void forceReconnectHost(host.id).catch(showForceReconnectError)
}
}
+5 -2
View File
@@ -37,6 +37,7 @@ import {
useCloseHost,
useForceReconnect
} from '../../../src/transport/client-context'
import { showForceReconnectError } from '../../../src/transport/force-reconnect-feedback'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
import {
@@ -814,7 +815,9 @@ export function HostScreen({
return (
<Pressable
style={styles.reconnectButton}
onPress={() => void forceReconnectHost(hostId!)}
onPress={() =>
void forceReconnectHost(hostId!).catch(showForceReconnectError)
}
hitSlop={8}
>
<Text style={styles.reconnectButtonText}>Reconnect</Text>
@@ -1075,7 +1078,7 @@ export function HostScreen({
{connState === 'auth-failed' && (
<AuthFailedBanner
canRetry={!!hostId}
onRetry={() => hostId && void forceReconnectHost(hostId)}
onRetry={() => hostId && void forceReconnectHost(hostId).catch(showForceReconnectError)}
onRepair={() => router.push('/pair-scan')}
onRemove={() => setConfirmRemoveHost(true)}
/>
@@ -59,6 +59,7 @@ import {
type MobileTerminalLinkOpenMode
} from '../../../../src/storage/preferences'
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
import { showForceReconnectError } from '../../../../src/transport/force-reconnect-feedback'
import {
useLastConnectedAt,
useReconnectAttempt
@@ -4453,7 +4454,7 @@ export default function SessionScreen() {
disabled={!showConnectionRetry}
onPress={() => {
if (hostId) {
void forceReconnectHost(hostId)
void forceReconnectHost(hostId).catch(showForceReconnectError)
}
}}
accessibilityRole={showConnectionRetry ? 'button' : undefined}
+2 -1
View File
@@ -37,6 +37,7 @@ import {
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { showForceReconnectError } from '../src/transport/force-reconnect-feedback'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import {
@@ -992,7 +993,7 @@ export default function HomeScreen() {
icon: RefreshCw,
onPress: () => {
setActionTarget(null)
void forceReconnectHost(host.id)
void forceReconnectHost(host.id).catch(showForceReconnectError)
}
})
if (isLive) {
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { showForceReconnectError } from '../transport/force-reconnect-feedback'
import type { RpcSuccess } from '../transport/types'
import type {
AiVaultListResult,
@@ -169,7 +170,7 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams
const retry = useCallback(() => {
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
void forceReconnect(hostId).catch(showForceReconnectError)
return
}
void loadSessions({ scope, force: false })
+5 -2
View File
@@ -11,6 +11,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, X } from 'lucide-react-native'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { showForceReconnectError } from '../transport/force-reconnect-feedback'
import { getWorktreeLabel } from '../session/worktree-label'
import {
flattenDirectoryCache,
@@ -249,7 +250,7 @@ export function MobileFileExplorerPanel(props: {
(relativePath: string) => {
if (connState !== 'connected' && hostId) {
pendingDirectoryRetriesRef.current.add(relativePath)
void forceReconnect(hostId)
void forceReconnect(hostId).catch(showForceReconnectError)
return
}
void loadDirectory(relativePath)
@@ -332,7 +333,9 @@ export function MobileFileExplorerPanel(props: {
<Pressable
style={styles.retryButton}
onPress={() =>
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('')
connState !== 'connected' && hostId
? void forceReconnect(hostId).catch(showForceReconnectError)
: void loadDirectory('')
}
>
<Text style={styles.retryText}>Retry</Text>
+2 -1
View File
@@ -6,6 +6,7 @@ import { ChevronLeft, Save } from 'lucide-react-native'
import { getWorktreeLabel } from '../session/worktree-label'
import { colors, spacing } from '../theme/mobile-theme'
import { useForceReconnect, useHostClient } from '../transport/client-context'
import { showForceReconnectError } from '../transport/force-reconnect-feedback'
import {
loadMobileFilePreview,
previewError,
@@ -164,7 +165,7 @@ export function MobileFilePreviewScreen({ route }: Props) {
(preview.status === 'error' && preview.reconnect) ||
connState !== 'connected'
) {
await forceReconnect(previewParams.hostId)
await forceReconnect(previewParams.hostId).catch(showForceReconnectError)
return
}
void loadPreview()
+6 -2
View File
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import EditHostScreen from '../app/h/[hostId]/edit'
const dependencies = vi.hoisted(() => ({
alert: vi.fn(),
back: vi.fn(),
forceReconnectHost: vi.fn(),
loadHosts: vi.fn(),
@@ -14,6 +15,7 @@ const dependencies = vi.hoisted(() => ({
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Alert: { alert: dependencies.alert },
KeyboardAvoidingView: 'KeyboardAvoidingView',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
@@ -137,6 +139,7 @@ describe('edit host handleSave', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.hostId = 'host-1'
dependencies.alert.mockReset()
dependencies.back.mockReset()
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
@@ -236,7 +239,7 @@ describe('edit host handleSave', () => {
act(() => renderer.unmount())
})
it('still navigates back and shows no error when the post-save reconnect rejects', async () => {
it('still navigates back and alerts when the post-save reconnect rejects', async () => {
dependencies.forceReconnectHost.mockRejectedValueOnce(new Error('connect failed'))
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
@@ -248,7 +251,7 @@ describe('edit host handleSave', () => {
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
expect(dependencies.back).toHaveBeenCalledTimes(1)
expect(findText(renderer, 'connect failed')).toBe(false)
expect(dependencies.alert).toHaveBeenCalledWith('Unable to reconnect', 'connect failed')
act(() => renderer.unmount())
})
@@ -287,6 +290,7 @@ describe('edit host load() error states', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.hostId = 'host-1'
dependencies.alert.mockReset()
dependencies.back.mockReset()
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
@@ -196,7 +196,9 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) {
openSendSheet,
retryAction: () => {
if (connState !== 'connected' && hostId) {
void onReconnect(hostId)
void Promise.resolve(onReconnect(hostId)).catch((error: unknown) => {
setActionError(error instanceof Error ? error.message : 'Unable to reconnect')
})
return
}
void loadReviewData()
@@ -5,6 +5,7 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { useForceReconnect } from '../transport/client-context'
import { showForceReconnectError } from '../transport/force-reconnect-feedback'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
@@ -89,7 +90,7 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
// MobileSourceControlPanel / issue #5049). The load effect re-runs via
// connState once the fresh client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
void forceReconnect(hostId).catch(showForceReconnectError)
return
}
setReloadNonce((n) => n + 1)
@@ -16,6 +16,7 @@ import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
import { showForceReconnectError } from '../transport/force-reconnect-feedback'
import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state'
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
@@ -254,7 +255,7 @@ export function MobileSourceControlPanel({
onPress={() => {
// Why: a parked reconnect loop makes retry useless — revive the connection instead (issue #5049); loadStatus re-runs on reconnect.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
void forceReconnect(hostId).catch(showForceReconnectError)
return
}
void loadStatus()
+16 -1
View File
@@ -270,7 +270,8 @@ describe('useHostClient', () => {
expect(stale.closeMock).toHaveBeenCalledOnce()
expect(fresh.sendRequest).toHaveBeenCalledWith('status.get', undefined, {
timeoutMs: 15_000,
budgetSpansConnect: true
budgetSpansConnect: true,
strictDeadline: true
})
expect(completed).toBe(false)
@@ -281,6 +282,20 @@ describe('useHostClient', () => {
harness.unmount()
})
it('rejects Force Reconnect when a replacement client cannot open', async () => {
const stale = makeFakeClient('connected')
connectMock.mockReturnValue(stale)
loadHostsMock.mockResolvedValueOnce([HOST]).mockResolvedValueOnce([])
const harness = await renderHarness(HOST.id)
await expect(harness.forceReconnect(HOST.id)).rejects.toThrow(
'Unable to open a replacement connection'
)
expect(stale.closeMock).toHaveBeenCalledOnce()
harness.unmount()
})
it('reports disconnected instead of hanging when the host id is unknown', async () => {
loadHostsMock.mockResolvedValue([])
+5 -5
View File
@@ -211,10 +211,11 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
storeRef.current.delete(hostId)
}
const fresh = await openEntry(hostId)
if (fresh) {
fresh.refCount = savedRefCount
await verifyForceReconnectRpcHealth(fresh.client)
if (!fresh) {
throw new Error('Unable to open a replacement connection')
}
fresh.refCount = savedRefCount
await verifyForceReconnectRpcHealth(fresh.client)
},
[openEntry]
)
@@ -451,8 +452,7 @@ export function useAllHostClients(hostIds: string[]) {
// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it.
export function useCloseHost(): (hostId: string) => void {
const ctx = useRpcClientContext()
return ctx.closeHost
return useRpcClientContext().closeHost
}
// Why: future-proof "Connection issues — try again" affordance.
@@ -0,0 +1,5 @@
import { Alert } from 'react-native'
export function showForceReconnectError(error: unknown): void {
Alert.alert('Unable to reconnect', error instanceof Error ? error.message : 'Please try again.')
}
@@ -40,7 +40,8 @@ describe('Force Reconnect RPC health', () => {
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenLastCalledWith('status.get', undefined, {
timeoutMs: 1_000,
budgetSpansConnect: true
budgetSpansConnect: true,
strictDeadline: true
})
})
})
@@ -1,24 +1,33 @@
import type { RpcClient } from './rpc-client'
import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { isLogicalClientCutoverError } from './stable-logical-rpc-client'
const FORCE_RECONNECT_HEALTH_TIMEOUT_MS = 15_000
export async function verifyForceReconnectRpcHealth(client: RpcClient): Promise<void> {
const deadline = Date.now() + FORCE_RECONNECT_HEALTH_TIMEOUT_MS
let lastCutover: unknown = null
let lastError: unknown = null
for (;;) {
const timeoutMs = deadline - Date.now()
if (timeoutMs <= 0) {
throw lastCutover ?? new Error('Force Reconnect health check timed out')
throw lastError ?? new Error('Force Reconnect health check timed out')
}
try {
await client.sendRequest('status.get', undefined, { timeoutMs, budgetSpansConnect: true })
await client.sendRequest('status.get', undefined, {
timeoutMs,
budgetSpansConnect: true,
strictDeadline: true
})
return
} catch (error) {
if (!isLogicalClientCutoverError(error)) {
if (
!isLogicalClientCutoverError(error) &&
!isRpcDeliveryUnknown(error) &&
!(error instanceof Error && error.message === 'Connection interrupted')
) {
throw error
}
lastCutover = error
lastError = error
}
}
}
@@ -243,4 +243,32 @@ describe('mobile relay RPC session', () => {
vi.useRealTimers()
}
})
it('keeps a Relay session when another control response proves it live', async () => {
const { session } = await authenticateSession()
vi.useFakeTimers()
try {
const stalled = session.sendRequest('browser.screenshot', {}, { timeoutMs: 100 })
const stalledOutcome = stalled.catch((error: unknown) => error)
const healthy = session.sendRequest('status.get', undefined, { timeoutMs: 100 })
await vi.advanceTimersByTimeAsync(0)
const requests = fakes.sendText.mock.calls.map(
([payload]) => JSON.parse(payload as string) as { id: string; method: string }
)
const healthRequest = requests.find(({ method }) => method === 'status.get')!
fakes.linkOptions!.onText(
JSON.stringify({ id: healthRequest.id, ok: true, result: {}, _meta: {} })
)
await expect(healthy).resolves.toMatchObject({ ok: true })
await vi.advanceTimersByTimeAsync(100)
await expect(stalledOutcome).resolves.toMatchObject({
message: 'relay RPC timed out: browser.screenshot'
})
expect(session.getState()).toBe('connected')
expect(fakes.close).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
@@ -39,6 +39,7 @@ export function connectMobileRelayRpcSession(args: {
const stateListeners = new Set<(state: ConnectionState) => void>()
let state: ConnectionState = 'connecting'
let requestCounter = 0
let controlResponseSequence = 0
let lastConnectedAt: number | null = null
let leaseExpiresAt: number | null = null
let resumeConfirmation: DeviceResumeConfirmed | null = null
@@ -148,13 +149,16 @@ export function connectMobileRelayRpcSession(args: {
return Promise.reject(new Error('relay session not connected'))
}
const id = nextId()
const requestControlResponseSequence = controlResponseSequence
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
// Why: the frame was written long ago — the desktop may have processed it.
const error = markRpcDeliveryUnknown(new Error(`relay RPC timed out: ${method}`))
reject(error)
fail(error)
if (controlResponseSequence === requestControlResponseSequence) {
fail(error)
}
}, timeoutMs)
pending.set(id, { resolve, reject, timer })
if (!sendFrame({ id, method, params })) {
@@ -181,6 +185,7 @@ export function connectMobileRelayRpcSession(args: {
}
const request = pending.get(value.id)
if (request) {
controlResponseSequence += 1
clearTimeout(request.timer)
pending.delete(value.id)
request.resolve(value)
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connect } from './rpc-client'
import { verifyForceReconnectRpcHealth } from './force-reconnect-rpc-health'
vi.mock('./e2ee', () => ({
generateKeyPair: () => ({
@@ -201,4 +202,25 @@ describe('mobile rpc-client request deadline', () => {
client.close()
})
it('keeps Force Reconnect inside its deadline after a late connection', async () => {
const client = connect('ws://desktop.invalid', 'token', 'server-key')
const verification = verifyForceReconnectRpcHealth(client)
let outcome = 'pending'
const settled = verification.catch((error: Error) => {
outcome = error.message
})
await vi.advanceTimersByTimeAsync(14_900)
mockSockets.at(-1)!.open()
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(99)
expect(outcome).toBe('pending')
await vi.advanceTimersByTimeAsync(1)
await settled
expect(outcome).toBe('Request timed out: status.get')
client.close()
})
})
+3
View File
@@ -59,6 +59,9 @@ export type SendRequestOptions = {
* against the post-connect clock, and squeezing them to the floor after a slow
* reconnect would fail sends that used to land. */
budgetSpansConnect?: boolean
/** Keep the connect-plus-request deadline strict instead of granting the
* normal post-connect acknowledgement floor. */
strictDeadline?: boolean
/** Reject immediately when not connected — a send parked in the connect wait
* replays stale terminal bytes into the PTY after reconnect. */
failWhenDisconnected?: boolean
+9 -5
View File
@@ -1,12 +1,14 @@
type RpcRequestBudgetOptions = {
timeoutMs?: number
budgetSpansConnect?: boolean
strictDeadline?: boolean
}
export type RpcRequestBudget = {
startedAt: number
timeoutMs?: number
deadline: number | null
strictDeadline: boolean
}
export const RPC_REQUEST_MIN_ACK_MS = 1_000
@@ -19,7 +21,8 @@ export function openRpcRequestBudget(
return {
startedAt: now,
...(timeoutMs === undefined ? {} : { timeoutMs }),
deadline: options?.budgetSpansConnect && timeoutMs !== undefined ? now + timeoutMs : null
deadline: options?.budgetSpansConnect && timeoutMs !== undefined ? now + timeoutMs : null,
strictDeadline: options?.strictDeadline === true
}
}
@@ -31,8 +34,9 @@ export function resolvePostConnectRequestTimeout(
if (budget.deadline === null) {
return budget.timeoutMs ?? fallbackMs
}
return Math.max(
Math.min(RPC_REQUEST_MIN_ACK_MS, budget.deadline - budget.startedAt),
budget.deadline - now
)
const remainingMs = budget.deadline - now
if (budget.strictDeadline) {
return Math.max(0, remainingMs)
}
return Math.max(Math.min(RPC_REQUEST_MIN_ACK_MS, budget.deadline - budget.startedAt), remainingMs)
}
@@ -105,18 +105,21 @@ describe('stable logical RPC client', () => {
const oldSession = new FakeSession('connected')
const nextSession = new FakeSession('connected')
const pending = deferred<RpcResponse>()
oldSession.sendRequest.mockReturnValue(pending.promise)
oldSession.sendRequest
.mockRejectedValueOnce(new Error('Connection interrupted'))
.mockReturnValue(pending.promise)
nextSession.sendRequest.mockResolvedValue(success('relay'))
const client = createStableLogicalRpcClient(oldSession, 'lan')
const verifying = verifyForceReconnectRpcHealth(client)
await Promise.resolve()
await vi.waitFor(() => expect(oldSession.sendRequest).toHaveBeenCalledTimes(2))
await client.migrateTo(nextSession, 'relay')
await expect(verifying).resolves.toBeUndefined()
expect(nextSession.sendRequest).toHaveBeenCalledWith('status.get', undefined, {
timeoutMs: expect.any(Number),
budgetSpansConnect: true
budgetSpansConnect: true,
strictDeadline: true
})
pending.resolve(success('late'))
})