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