diff --git a/mobile/src/transport/force-reconnect-rpc-health.test.ts b/mobile/src/transport/force-reconnect-rpc-health.test.ts index e1584c05ea6..8962cd39c4b 100644 --- a/mobile/src/transport/force-reconnect-rpc-health.test.ts +++ b/mobile/src/transport/force-reconnect-rpc-health.test.ts @@ -2,8 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient, SendRequestOptions } from './rpc-client' import type { ConnectionState, RpcResponse } from './types' import { verifyForceReconnectRpcHealth } from './force-reconnect-rpc-health' +import { waitForMobileRelayRpcConnected } from './mobile-relay-rpc-connect-wait' import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' -import { LogicalClientCutoverError } from './stable-logical-rpc-client' +import { + createStableLogicalRpcClient, + LogicalClientCutoverError +} from './stable-logical-rpc-client' import { RpcApplicationResponsiveness } from './rpc-application-responsiveness' describe('Force Reconnect RPC health', () => { @@ -183,4 +187,58 @@ describe('Force Reconnect RPC health', () => { await expect(verification).resolves.toBeUndefined() expect(sendRequest).toHaveBeenCalledTimes(2) }) + + it('waits for stable-client migration when Relay disconnects before probe delivery', async () => { + vi.useFakeTimers() + let relayDeliveries = 0 + const relaySendRequest = vi.fn(async () => { + await waitForMobileRelayRpcConnected({ + getState: () => 'disconnected', + subscribe: () => () => {}, + timeoutMs: 15_000 + }) + relayDeliveries += 1 + return { id: 'rpc-old', ok: true, result: {} } as RpcResponse + }) + const relay = { + sendRequest: relaySendRequest, + getState: () => 'disconnected' as const, + onStateChange: () => () => {}, + close: vi.fn() + } as unknown as RpcClient + const replacementSendRequest = vi.fn( + async () => ({ id: 'rpc-new', ok: true, result: {} }) as RpcResponse + ) + const replacement = { + sendRequest: replacementSendRequest, + getState: () => 'connected' as const, + onStateChange: () => () => {}, + close: vi.fn() + } as unknown as RpcClient + const logical = createStableLogicalRpcClient(relay, 'relay') + let outcome = 'pending' + const verification = verifyForceReconnectRpcHealth(logical).then( + () => { + outcome = 'resolved' + }, + (error: Error) => { + outcome = error.message + } + ) + + await vi.advanceTimersByTimeAsync(14_000) + expect(outcome).toBe('pending') + await logical.migrateTo(replacement, 'relay') + await verification + + expect(outcome).toBe('resolved') + expect(relayDeliveries).toBe(0) + expect(relaySendRequest).toHaveBeenCalledOnce() + expect(replacementSendRequest).toHaveBeenCalledOnce() + expect(replacementSendRequest).toHaveBeenCalledWith( + 'worktree.ps', + { limit: 1 }, + expect.objectContaining({ timeoutMs: 1_000, strictDeadline: true }) + ) + }) }) diff --git a/mobile/src/transport/force-reconnect-rpc-health.ts b/mobile/src/transport/force-reconnect-rpc-health.ts index 56632401549..00a3f94d0d9 100644 --- a/mobile/src/transport/force-reconnect-rpc-health.ts +++ b/mobile/src/transport/force-reconnect-rpc-health.ts @@ -53,7 +53,11 @@ function isRecoverableHealthError( isLogicalClientCutoverError(error) || isRpcDeliveryUnknown(error) || (error instanceof Error && - ['Connection interrupted', 'relay session not connected'].includes(error.message)) + [ + 'Connection interrupted', + 'relay session disconnected', + 'relay session not connected' + ].includes(error.message)) ) }