diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 768eb0d6902..ff9158f7e0c 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectionState, HostProfile } from './types' import type { RpcClient } from './rpc-client' +import { dropSharedHostListLoad, shareHostListLoad } from './host-list-load-sharing' const connectMock = vi.fn() const loadHostsMock = vi.fn() @@ -149,6 +150,7 @@ beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true connectMock.mockReset() loadHostsMock.mockReset() + dropSharedHostListLoad() }) describe('useHostClient', () => { @@ -328,6 +330,25 @@ describe('useHostClient', () => { harness.unmount() }) + it('drops a stalled shared host lookup before opening a Force Reconnect replacement', async () => { + const stale = makeFakeClient('connected') + const fresh = makeFakeClient('connecting') + fresh.sendRequest = vi.fn(async () => ({ id: 'health', ok: true, result: {} })) + connectMock.mockReturnValueOnce(stale).mockReturnValueOnce(fresh) + loadHostsMock.mockResolvedValue([HOST]) + const parked = new Promise(() => {}) + const sharedLoad = vi.fn().mockReturnValueOnce(parked).mockResolvedValueOnce([HOST]) + const parkedLookup = shareHostListLoad(sharedLoad) + + const harness = await renderHarness(HOST.id) + harness.primeHosts([HOST]) + await act(async () => harness.forceReconnect(HOST.id)) + + expect(shareHostListLoad(sharedLoad)).not.toBe(parkedLookup) + expect(sharedLoad).toHaveBeenCalledTimes(2) + harness.unmount() + }) + it('reconnects with relay metadata published by the endpoint lifecycle', async () => { const stale = makeFakeClient('connected') const fresh = makeFakeClient('connecting') diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 2226df53f36..4961a4516df 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -16,7 +16,7 @@ import { HostForceReconnectCoordinator, type HostReconnectEntry } from './host-f import { HostReconnectProfileCache, type HostOpenProfile } from './host-reconnect-profile-cache' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { HostClientOpenRegistry } from './host-client-open-registry' -import { loadHostClientOpenProfile } from './host-client-open-profile' +import { cancelHostClientOpenProfile, loadHostClientOpenProfile } from './host-client-open-profile' import { loadHosts } from './host-store' import { openHostLogicalClient } from './host-logical-client' import { clientActivePath } from './rpc-client-active-path' @@ -207,7 +207,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { notifyHostState(hostId, 'disconnected') notifyAllHosts() }, - cancelPendingOpen: () => pendingOpensRef.current.cancel(hostId), + cancelPendingOpen: () => cancelHostClientOpenProfile(pendingOpensRef.current, hostId), openReplacement: () => openEntry(hostId, profile) }) }, diff --git a/mobile/src/transport/host-client-open-profile.ts b/mobile/src/transport/host-client-open-profile.ts index bd5b1d4a0d3..8b40f3601bf 100644 --- a/mobile/src/transport/host-client-open-profile.ts +++ b/mobile/src/transport/host-client-open-profile.ts @@ -1,5 +1,6 @@ -import type { HostClientOpenTicket } from './host-client-open-registry' -import { HostReconnectProfileCache } from './host-reconnect-profile-cache' +import type { HostClientOpenTicket, HostClientOpenRegistry } from './host-client-open-registry' +import type { HostReconnectProfileCache } from './host-reconnect-profile-cache' +import { dropSharedHostListLoad } from './host-list-load-sharing' import type { HostProfile } from './types' type HostClientOpenProfileOptions = { @@ -10,6 +11,14 @@ type HostClientOpenProfileOptions = { onUnavailable: () => void } +export function cancelHostClientOpenProfile( + registry: HostClientOpenRegistry, + hostId: string +): void { + registry.cancel(hostId) + dropSharedHostListLoad() +} + export async function loadHostClientOpenProfile( options: HostClientOpenProfileOptions ): Promise<{ host: HostProfile; version: number } | null> { diff --git a/mobile/src/transport/host-store-endpoint.test.ts b/mobile/src/transport/host-store-endpoint.test.ts index 95d9c364b88..c7c66a62334 100644 --- a/mobile/src/transport/host-store-endpoint.test.ts +++ b/mobile/src/transport/host-store-endpoint.test.ts @@ -1,6 +1,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { updateHostNameAndEndpoint } from './host-store' +import { loadHosts, resetHostStoreForTests, updateHostNameAndEndpoint } from './host-store' +import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' vi.mock('@react-native-async-storage/async-storage', () => ({ default: { @@ -25,6 +27,9 @@ describe('updateHostNameAndEndpoint', () => { beforeEach(() => { vi.mocked(AsyncStorage.getItem).mockReset() vi.mocked(AsyncStorage.setItem).mockReset() + vi.mocked(SecureStore.getItemAsync).mockReset().mockResolvedValue('device-token') + resetHostStoreForTests() + resetMobileRelayHostOverlayStoreForTests() }) const stored = [ @@ -92,4 +97,74 @@ describe('updateHostNameAndEndpoint', () => { ) expect(AsyncStorage.setItem).not.toHaveBeenCalled() }) + + it('does not commit an endpoint when its relay overlay update fails', async () => { + const relayHostId = 'AbCdEf0123_-xyZ9' + const overlay = { + v: 2, + hostId: 'host-1', + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: stored[0]!.endpoint }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay.onorca.dev/v1/connect/host' } + ], + relayHostId, + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay.onorca.dev', + assignmentEpoch: 1, + relayHostId, + e2eeFraming: 2 + } + } + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => + key === 'orca:mobile-relay:host-overlays:v2' + ? JSON.stringify([overlay]) + : JSON.stringify(stored) + ) + vi.mocked(AsyncStorage.setItem).mockImplementation(async (key) => { + if (key === 'orca:mobile-relay:host-overlays:v2') { + throw new Error('overlay unavailable') + } + }) + + await expect( + updateHostNameAndEndpoint('host-1', { endpoint: 'ws://192.168.1.10:6768' }) + ).rejects.toThrow('overlay unavailable') + expect(AsyncStorage.setItem).not.toHaveBeenCalledWith('orca:hosts', expect.any(String)) + }) + + it('treats the base endpoint as authoritative over an ahead relay overlay', async () => { + const relayHostId = 'AbCdEf0123_-xyZ9' + const overlay = { + v: 2, + hostId: 'host-1', + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay.onorca.dev/v1/connect/host' } + ], + relayHostId, + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay.onorca.dev', + assignmentEpoch: 1, + relayHostId, + e2eeFraming: 2 + } + } + vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => + key === 'orca:mobile-relay:host-overlays:v2' + ? JSON.stringify([overlay]) + : JSON.stringify(stored) + ) + + const hosts = await loadHosts() + + expect(hosts.find(({ id }) => id === 'host-1')?.endpoints).toContainEqual({ + id: 'direct-primary', + kind: 'lan', + url: stored[0]!.endpoint + }) + }) }) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index d22f43c765f..5cc86ddce05 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -113,7 +113,11 @@ async function doLoadHosts(): Promise { deviceToken: token, ...(overlay ? { - endpoints: overlay.endpoints, + endpoints: overlay.endpoints.map((endpoint) => + endpoint.id === 'direct-primary' && endpoint.kind !== 'relay' + ? { ...endpoint, url: stored.endpoint } + : endpoint + ), relayHostId: overlay.relayHostId, relay: overlay.relay } @@ -168,12 +172,14 @@ async function readStoredHostsForMutation(): Promise { } export async function mutateStoredHosts( - update: (hosts: StoredHostProfile[]) => StoredHostProfile[] + update: (hosts: StoredHostProfile[]) => StoredHostProfile[], + beforeWrite?: () => Promise ): Promise { const mutation = hostListMutation.then(async () => { const current = await readStoredHostsForMutation() const next = update(current) if (next !== current) { + await beforeWrite?.() await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)) hostListLoads.dropSharedHostListLoad() } @@ -281,24 +287,25 @@ export async function updateHostNameAndEndpoint( updates: { name?: string; endpoint?: string } ): Promise { await serializeHostProfilePublication(hostId, async () => { - await mutateStoredHosts((hosts) => { - const index = hosts.findIndex((host) => host.id === hostId) - if (index < 0) { - throw new Error('Host not found') - } - const next = hosts.slice() - if (updates.name !== undefined) { - next[index] = { ...next[index]!, name: updates.name } - } - if (updates.endpoint !== undefined) { - next[index] = { ...next[index]!, endpoint: updates.endpoint } - } - return next - }) - if (updates.endpoint !== undefined) { - await updateMobileRelayHostOverlayDirectEndpoint(hostId, updates.endpoint) - hostListLoads.dropSharedHostListLoad() - } + await mutateStoredHosts( + (hosts) => { + const index = hosts.findIndex((host) => host.id === hostId) + if (index < 0) { + throw new Error('Host not found') + } + const next = hosts.slice() + if (updates.name !== undefined) { + next[index] = { ...next[index]!, name: updates.name } + } + if (updates.endpoint !== undefined) { + next[index] = { ...next[index]!, endpoint: updates.endpoint } + } + return next + }, + updates.endpoint === undefined + ? undefined + : () => updateMobileRelayHostOverlayDirectEndpoint(hostId, updates.endpoint!) + ) }) } diff --git a/mobile/src/transport/mobile-endpoint-supervisor-support.ts b/mobile/src/transport/mobile-endpoint-supervisor-support.ts index 952e6293af8..1653cb3d7d9 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-support.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-support.ts @@ -28,11 +28,7 @@ export async function persistRelayHost( ].filter(({ kind }) => kind !== 'relay') endpoints.push({ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) }) const updated = { ...host, endpoints, relayHostId: relay.relayHostId, relay } - if (beforePublish) { - await saveHost(updated, beforePublish) - } else { - await saveHost(updated) - } + await saveHost(updated, beforePublish) return updated } diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 215823ddcb8..e2207d49367 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -272,7 +272,8 @@ describe('mobile endpoint supervisor', () => { expect(deps.resolveRelay).toHaveBeenCalledOnce() expect(openRelay).toHaveBeenLastCalledWith(resolved, expect.any(Object), expect.any(String)) expect(deps.saveHost).toHaveBeenCalledWith( - expect.objectContaining({ relay: resolved, endpoint: host.endpoint }) + expect.objectContaining({ relay: resolved, endpoint: host.endpoint }), + undefined ) expect(onHostUpdated).toHaveBeenCalledWith( expect.objectContaining({ relay: resolved, endpoint: host.endpoint }) diff --git a/mobile/src/transport/rpc-client-context-contract.ts b/mobile/src/transport/rpc-client-context-contract.ts index af39285445a..3a55f8cc98a 100644 --- a/mobile/src/transport/rpc-client-context-contract.ts +++ b/mobile/src/transport/rpc-client-context-contract.ts @@ -12,7 +12,7 @@ export type RpcClientContextValue = { getLastConnectedAt: (hostId: string) => number | null getActivePath: (hostId: string) => MobileConnectionPath subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void - getAllClients: () => Array<{ hostId: string; client: RpcClient }> + getAllClients: () => { hostId: string; client: RpcClient }[] subscribeAllHosts: (listener: () => void) => () => void primeHosts: (hosts: HostProfile[]) => void }