From 6637c8d179ea730d6f48ff4ecec2dac8663e4a55 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:40:40 -0700 Subject: [PATCH] fix(mobile): harden reconnect verification edges --- mobile/app/h/[hostId]/edit.tsx | 3 +- mobile/app/h/[hostId]/index.tsx | 7 +++-- .../app/h/[hostId]/session/[worktreeId].tsx | 3 +- mobile/app/index.tsx | 3 +- .../use-mobile-agent-history-state.ts | 3 +- mobile/src/files/MobileFileExplorerPanel.tsx | 7 +++-- mobile/src/files/MobileFilePreviewScreen.tsx | 3 +- mobile/src/host-edit-save-flow.test.ts | 8 ++++-- .../use-mobile-diff-review-interactions.ts | 4 ++- .../source-control/MobileGitHistoryList.tsx | 3 +- .../MobileSourceControlPanel.tsx | 3 +- mobile/src/transport/client-context.test.ts | 17 ++++++++++- mobile/src/transport/client-context.tsx | 10 +++---- .../src/transport/force-reconnect-feedback.ts | 5 ++++ .../force-reconnect-rpc-health.test.ts | 3 +- .../transport/force-reconnect-rpc-health.ts | 19 +++++++++---- .../mobile-relay-rpc-session.test.ts | 28 +++++++++++++++++++ .../src/transport/mobile-relay-rpc-session.ts | 7 ++++- .../rpc-client-request-deadline.test.ts | 22 +++++++++++++++ mobile/src/transport/rpc-client.ts | 3 ++ mobile/src/transport/rpc-request-budget.ts | 14 ++++++---- .../stable-logical-rpc-client.test.ts | 9 ++++-- 22 files changed, 149 insertions(+), 35 deletions(-) create mode 100644 mobile/src/transport/force-reconnect-feedback.ts diff --git a/mobile/app/h/[hostId]/edit.tsx b/mobile/app/h/[hostId]/edit.tsx index cfb91fdae22..5983cd4fc74 100644 --- a/mobile/app/h/[hostId]/edit.tsx +++ b/mobile/app/h/[hostId]/edit.tsx @@ -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) } } diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index d88fb6699aa..49d5f9569c9 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -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 ( void forceReconnectHost(hostId!)} + onPress={() => + void forceReconnectHost(hostId!).catch(showForceReconnectError) + } hitSlop={8} > Reconnect @@ -1075,7 +1078,7 @@ export function HostScreen({ {connState === 'auth-failed' && ( hostId && void forceReconnectHost(hostId)} + onRetry={() => hostId && void forceReconnectHost(hostId).catch(showForceReconnectError)} onRepair={() => router.push('/pair-scan')} onRemove={() => setConfirmRemoveHost(true)} /> diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 4ec0e564a79..d7afdefac1c 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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} diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 4000906c933..c2dc2644f5e 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -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) { diff --git a/mobile/src/agent-history/use-mobile-agent-history-state.ts b/mobile/src/agent-history/use-mobile-agent-history-state.ts index a0a82bee019..5d37ff4d647 100644 --- a/mobile/src/agent-history/use-mobile-agent-history-state.ts +++ b/mobile/src/agent-history/use-mobile-agent-history-state.ts @@ -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 }) diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx index 7e67bf81b5f..1f8de4e9009 100644 --- a/mobile/src/files/MobileFileExplorerPanel.tsx +++ b/mobile/src/files/MobileFileExplorerPanel.tsx @@ -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: { - connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('') + connState !== 'connected' && hostId + ? void forceReconnect(hostId).catch(showForceReconnectError) + : void loadDirectory('') } > Retry diff --git a/mobile/src/files/MobileFilePreviewScreen.tsx b/mobile/src/files/MobileFilePreviewScreen.tsx index 0879e02ec7e..c533c4f0bc7 100644 --- a/mobile/src/files/MobileFilePreviewScreen.tsx +++ b/mobile/src/files/MobileFilePreviewScreen.tsx @@ -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() diff --git a/mobile/src/host-edit-save-flow.test.ts b/mobile/src/host-edit-save-flow.test.ts index aa0069a7c69..63d7a913372 100644 --- a/mobile/src/host-edit-save-flow.test.ts +++ b/mobile/src/host-edit-save-flow.test.ts @@ -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]) diff --git a/mobile/src/session/use-mobile-diff-review-interactions.ts b/mobile/src/session/use-mobile-diff-review-interactions.ts index 079874933e2..37dcc1ee0a0 100644 --- a/mobile/src/session/use-mobile-diff-review-interactions.ts +++ b/mobile/src/session/use-mobile-diff-review-interactions.ts @@ -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() diff --git a/mobile/src/source-control/MobileGitHistoryList.tsx b/mobile/src/source-control/MobileGitHistoryList.tsx index d88fae540d8..110a2fea5d0 100644 --- a/mobile/src/source-control/MobileGitHistoryList.tsx +++ b/mobile/src/source-control/MobileGitHistoryList.tsx @@ -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) diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx index 9c3ebb27062..edf4e459b8e 100644 --- a/mobile/src/source-control/MobileSourceControlPanel.tsx +++ b/mobile/src/source-control/MobileSourceControlPanel.tsx @@ -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() diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 32bbc528ca3..ffe65751857 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -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([]) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 267243dd57b..9a113697d61 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -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. diff --git a/mobile/src/transport/force-reconnect-feedback.ts b/mobile/src/transport/force-reconnect-feedback.ts new file mode 100644 index 00000000000..5bea5901c7e --- /dev/null +++ b/mobile/src/transport/force-reconnect-feedback.ts @@ -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.') +} diff --git a/mobile/src/transport/force-reconnect-rpc-health.test.ts b/mobile/src/transport/force-reconnect-rpc-health.test.ts index cb75c5a938b..77ee3bf7c1f 100644 --- a/mobile/src/transport/force-reconnect-rpc-health.test.ts +++ b/mobile/src/transport/force-reconnect-rpc-health.test.ts @@ -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 }) }) }) diff --git a/mobile/src/transport/force-reconnect-rpc-health.ts b/mobile/src/transport/force-reconnect-rpc-health.ts index 66420867680..9731aa8b0dd 100644 --- a/mobile/src/transport/force-reconnect-rpc-health.ts +++ b/mobile/src/transport/force-reconnect-rpc-health.ts @@ -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 { 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 } } } diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index fdf694e7951..7ef3649087d 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -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() + } + }) }) diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index 6e57de581cd..30ee8d4361b 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -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) diff --git a/mobile/src/transport/rpc-client-request-deadline.test.ts b/mobile/src/transport/rpc-client-request-deadline.test.ts index 54e42b6182d..bfb22171ec1 100644 --- a/mobile/src/transport/rpc-client-request-deadline.test.ts +++ b/mobile/src/transport/rpc-client-request-deadline.test.ts @@ -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() + }) }) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 058dba8d8bd..f585afdb14a 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -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 diff --git a/mobile/src/transport/rpc-request-budget.ts b/mobile/src/transport/rpc-request-budget.ts index aecbe023cfd..17166fa929a 100644 --- a/mobile/src/transport/rpc-request-budget.ts +++ b/mobile/src/transport/rpc-request-budget.ts @@ -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) } diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index b9c290bcddc..25ff35438de 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -105,18 +105,21 @@ describe('stable logical RPC client', () => { const oldSession = new FakeSession('connected') const nextSession = new FakeSession('connected') const pending = deferred() - 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')) })