fix(mobile): close final reconnect review gaps

This commit is contained in:
Brennan Benson
2026-08-01 06:02:18 -07:00
parent 357275ba56
commit 7752bfb8a9
8 changed files with 141 additions and 32 deletions
@@ -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<HostProfile[]>(() => {})
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')
+2 -2
View File
@@ -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)
})
},
@@ -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> {
@@ -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
})
})
})
+27 -20
View File
@@ -113,7 +113,11 @@ async function doLoadHosts(): Promise<HostProfile[]> {
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<StoredHostProfile[]> {
}
export async function mutateStoredHosts(
update: (hosts: StoredHostProfile[]) => StoredHostProfile[]
update: (hosts: StoredHostProfile[]) => StoredHostProfile[],
beforeWrite?: () => Promise<void>
): Promise<void> {
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<void> {
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!)
)
})
}
@@ -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
}
@@ -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 })
@@ -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
}