mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(mobile): harden reconnect verification edges
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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([])
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user