mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(mobile): close reconnect review gaps
This commit is contained in:
@@ -290,7 +290,7 @@ describe('useHostClient', () => {
|
||||
|
||||
expect(stale.closeMock).toHaveBeenCalledOnce()
|
||||
expect(fresh.sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.list',
|
||||
'worktree.ps',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs: expect.any(Number),
|
||||
@@ -628,7 +628,7 @@ describe('useHostClient', () => {
|
||||
expect(connectMock.mock.calls[2]?.[0]).toEqual(updatedHost)
|
||||
expect(oldReplacement.closeMock).toHaveBeenCalledOnce()
|
||||
expect(newReplacement.sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.list',
|
||||
'worktree.ps',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs: expect.any(Number),
|
||||
|
||||
@@ -3,7 +3,6 @@ import { loadStoredHostIdentity } from './host-store'
|
||||
import { readHostDeviceToken } from './host-device-token-store'
|
||||
import {
|
||||
getHostEndpointPublicationLifecycle,
|
||||
getHostProfilePublicationRevision,
|
||||
getPublishedHostIdentity,
|
||||
serializeHostProfilePublication,
|
||||
type HostEndpointPublicationLifecycle
|
||||
@@ -116,6 +115,7 @@ function requireCurrentPublication(
|
||||
endpointLifecycle: HostEndpointPublicationLifecycle
|
||||
): void {
|
||||
const publishedIdentity = getPublishedHostIdentity(host.id)
|
||||
const currentLifecycle = getHostEndpointPublicationLifecycle(host.id)
|
||||
if (
|
||||
publishedIdentity &&
|
||||
(publishedIdentity.deviceToken !== host.deviceToken ||
|
||||
@@ -124,8 +124,8 @@ function requireCurrentPublication(
|
||||
throw new MobileRelayUpgradeHostSupersededError('mobile relay upgrade host was re-paired')
|
||||
}
|
||||
if (
|
||||
getHostProfilePublicationRevision(host.id) !== endpointLifecycle.profileRevision ||
|
||||
getHostEndpointPublicationLifecycle(host.id).generation !== endpointLifecycle.generation
|
||||
currentLifecycle.endpointRevision !== endpointLifecycle.endpointRevision ||
|
||||
currentLifecycle.generation !== endpointLifecycle.generation
|
||||
) {
|
||||
throw new MobileRelayUpgradeLifecycleRetiredError('mobile relay endpoint lifecycle was retired')
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('Force Reconnect RPC health', () => {
|
||||
expect(outcome).toBe('stalled')
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(
|
||||
'worktree.list',
|
||||
'worktree.ps',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs: 1_000,
|
||||
@@ -86,13 +86,59 @@ describe('Force Reconnect RPC health', () => {
|
||||
|
||||
await expect(verifyForceReconnectRpcHealth(client)).resolves.toBeUndefined()
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.list',
|
||||
'worktree.ps',
|
||||
{ limit: 1 },
|
||||
expect.objectContaining({ strictDeadline: true })
|
||||
)
|
||||
expect(responsiveness.getUnresponsiveSince()).toBeNull()
|
||||
})
|
||||
|
||||
it('uses an application probe allowed by older desktop hosts', async () => {
|
||||
const sendRequest = vi.fn(async (method: string) =>
|
||||
method === 'worktree.list'
|
||||
? ({
|
||||
id: 'rpc-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: "Method 'worktree.list' is not available to mobile clients"
|
||||
}
|
||||
} as RpcResponse)
|
||||
: ({ id: 'rpc-1', ok: true, result: {} } as RpcResponse)
|
||||
)
|
||||
const client = {
|
||||
sendRequest,
|
||||
getState: () => 'connected' as const
|
||||
} as unknown as RpcClient
|
||||
|
||||
await expect(verifyForceReconnectRpcHealth(client)).resolves.toBeUndefined()
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.ps',
|
||||
{ limit: 1 },
|
||||
expect.objectContaining({ strictDeadline: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an unsuccessful application response', async () => {
|
||||
const responsiveness = new RpcApplicationResponsiveness()
|
||||
responsiveness.recordTimeout('browser.screenshot', 123)
|
||||
const sendRequest = vi.fn(async (method: string) => {
|
||||
responsiveness.recordResponse(method)
|
||||
return {
|
||||
id: 'rpc-1',
|
||||
ok: false,
|
||||
error: { code: 'runtime_error', message: 'worktree scan failed' }
|
||||
} as RpcResponse
|
||||
})
|
||||
const client = {
|
||||
sendRequest,
|
||||
getState: () => 'connected' as const,
|
||||
getRpcUnresponsiveSince: () => responsiveness.getUnresponsiveSince()
|
||||
} as unknown as RpcClient
|
||||
|
||||
await expect(verifyForceReconnectRpcHealth(client)).rejects.toThrow('worktree scan failed')
|
||||
})
|
||||
|
||||
it('fails when authorization retries reach auth-failed', async () => {
|
||||
const sendRequest = vi.fn(async () => {
|
||||
throw new Error('Unauthorized — pairing may be revoked')
|
||||
|
||||
@@ -15,8 +15,8 @@ export async function verifyForceReconnectRpcHealth(
|
||||
throw lastError ?? new Error('Force Reconnect health check timed out')
|
||||
}
|
||||
try {
|
||||
await client.sendRequest(
|
||||
'worktree.list',
|
||||
const response = await client.sendRequest(
|
||||
'worktree.ps',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs,
|
||||
@@ -24,6 +24,9 @@ export async function verifyForceReconnectRpcHealth(
|
||||
strictDeadline: true
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
if (client.getRpcUnresponsiveSince?.() != null) {
|
||||
throw new Error('Application RPC channel is still not responding')
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { HostProfile } from './types'
|
||||
|
||||
const pendingByHost = new Map<string, Promise<void>>()
|
||||
const revisionByHost = new Map<string, number>()
|
||||
const endpointRevisionByHost = new Map<string, number>()
|
||||
const endpointGenerationByHost = new Map<string, number>()
|
||||
const publishedIdentityByHost = new Map<string, Pick<HostProfile, 'deviceToken' | 'publicKeyB64'>>()
|
||||
|
||||
export type HostEndpointPublicationLifecycle = Readonly<{
|
||||
generation: number
|
||||
profileRevision: number
|
||||
endpointRevision: number
|
||||
}>
|
||||
|
||||
export function getHostProfilePublicationRevision(hostId: string): number {
|
||||
@@ -18,6 +19,11 @@ export function recordHostProfileMutation(hostId: string): void {
|
||||
revisionByHost.set(hostId, getHostProfilePublicationRevision(hostId) + 1)
|
||||
}
|
||||
|
||||
export function recordHostEndpointMutation(hostId: string): void {
|
||||
recordHostProfileMutation(hostId)
|
||||
advanceHostEndpointRevision(hostId)
|
||||
}
|
||||
|
||||
export function getPublishedHostIdentity(
|
||||
hostId: string
|
||||
): Pick<HostProfile, 'deviceToken' | 'publicKeyB64'> | undefined {
|
||||
@@ -34,7 +40,7 @@ export function recordDurableHostIdentity(
|
||||
}
|
||||
|
||||
export function retireHostProfilePublication(hostId: string): Promise<void> | null {
|
||||
revisionByHost.set(hostId, getHostProfilePublicationRevision(hostId) + 1)
|
||||
recordHostEndpointMutation(hostId)
|
||||
endpointGenerationByHost.set(hostId, (endpointGenerationByHost.get(hostId) ?? 0) + 1)
|
||||
publishedIdentityByHost.delete(hostId)
|
||||
const pending = pendingByHost.get(hostId) ?? null
|
||||
@@ -49,7 +55,7 @@ export function beginHostEndpointPublicationLifecycle(
|
||||
endpointGenerationByHost.set(hostId, generation)
|
||||
return {
|
||||
generation,
|
||||
profileRevision: getHostProfilePublicationRevision(hostId)
|
||||
endpointRevision: getHostEndpointRevision(hostId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +64,7 @@ export function getHostEndpointPublicationLifecycle(
|
||||
): HostEndpointPublicationLifecycle {
|
||||
return {
|
||||
generation: endpointGenerationByHost.get(hostId) ?? 0,
|
||||
profileRevision: getHostProfilePublicationRevision(hostId)
|
||||
endpointRevision: getHostEndpointRevision(hostId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,10 +100,19 @@ export function publishHostProfileTransaction(
|
||||
await saveHost(host)
|
||||
requirePublicationRevision(host.id, publicationRevision)
|
||||
revisionByHost.set(host.id, publicationRevision + 1)
|
||||
advanceHostEndpointRevision(host.id)
|
||||
recordDurableHostIdentity(host)
|
||||
})
|
||||
}
|
||||
|
||||
function getHostEndpointRevision(hostId: string): number {
|
||||
return endpointRevisionByHost.get(hostId) ?? 0
|
||||
}
|
||||
|
||||
function advanceHostEndpointRevision(hostId: string): void {
|
||||
endpointRevisionByHost.set(hostId, getHostEndpointRevision(hostId) + 1)
|
||||
}
|
||||
|
||||
function requirePublicationRevision(hostId: string, expected: number): void {
|
||||
if (getHostProfilePublicationRevision(hostId) !== expected) {
|
||||
throw new Error('host profile publication was retired')
|
||||
@@ -108,6 +123,7 @@ function requirePublicationRevision(hostId: string, expected: number): void {
|
||||
export function resetHostProfilePublicationForTests(): void {
|
||||
pendingByHost.clear()
|
||||
revisionByHost.clear()
|
||||
endpointRevisionByHost.clear()
|
||||
endpointGenerationByHost.clear()
|
||||
publishedIdentityByHost.clear()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@ import * as SecureStore from 'expo-secure-store'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadHosts, resetHostStoreForTests, updateHostNameAndEndpoint } from './host-store'
|
||||
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
|
||||
import {
|
||||
saveExistingHostRelayRouting,
|
||||
writeExistingHostRelayCredentialBundle
|
||||
} from './existing-host-relay-routing'
|
||||
import {
|
||||
beginHostEndpointPublicationLifecycle,
|
||||
getHostEndpointPublicationLifecycle
|
||||
} from './host-profile-publication'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: {
|
||||
@@ -78,8 +86,86 @@ describe('updateHostNameAndEndpoint', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps queued Relay publication valid across a rename-only save', async () => {
|
||||
let storedHosts = stored
|
||||
let storedOverlays: unknown[] = []
|
||||
let releaseRename: (() => void) | null = null
|
||||
let markRenameStarted: (() => void) | null = null
|
||||
const renameStarted = new Promise<void>((resolve) => {
|
||||
markRenameStarted = resolve
|
||||
})
|
||||
const renamePending = new Promise<void>((resolve) => {
|
||||
releaseRename = resolve
|
||||
})
|
||||
vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) =>
|
||||
key === 'orca:hosts' ? JSON.stringify(storedHosts) : JSON.stringify(storedOverlays)
|
||||
)
|
||||
vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => {
|
||||
if (key === 'orca:hosts') {
|
||||
markRenameStarted?.()
|
||||
await renamePending
|
||||
storedHosts = JSON.parse(value) as typeof stored
|
||||
} else {
|
||||
storedOverlays = JSON.parse(value) as unknown[]
|
||||
}
|
||||
})
|
||||
const lifecycle = beginHostEndpointPublicationLifecycle('host-1')
|
||||
const rename = updateHostNameAndEndpoint('host-1', { name: 'Home Desk' })
|
||||
await renameStarted
|
||||
|
||||
const relayHost = {
|
||||
...stored[0]!,
|
||||
deviceToken: 'device-token',
|
||||
endpoints: [
|
||||
{ id: 'direct-primary', kind: 'lan' as const, url: stored[0]!.endpoint },
|
||||
{
|
||||
id: 'relay-primary',
|
||||
kind: 'relay' as const,
|
||||
url: 'wss://relay.onorca.dev/v1/connect/host'
|
||||
}
|
||||
],
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relay: {
|
||||
v: 1 as const,
|
||||
directorUrl: 'https://relay.onorca.dev',
|
||||
cellUrl: 'https://relay.onorca.dev',
|
||||
assignmentEpoch: 1,
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
e2eeFraming: 2 as const
|
||||
}
|
||||
}
|
||||
const routing = saveExistingHostRelayRouting(relayHost, undefined, lifecycle)
|
||||
releaseRename?.()
|
||||
|
||||
await rename
|
||||
expect(getHostEndpointPublicationLifecycle('host-1')).toEqual(lifecycle)
|
||||
await expect(routing).resolves.toEqual(expect.any(Number))
|
||||
const writeBundle = vi.fn(async () => {})
|
||||
await expect(
|
||||
writeExistingHostRelayCredentialBundle(
|
||||
relayHost,
|
||||
{
|
||||
v: 1,
|
||||
hostId: relayHost.id,
|
||||
deviceToken: relayHost.deviceToken,
|
||||
current: {
|
||||
token: 'A'.repeat(43),
|
||||
hash: 'B'.repeat(43),
|
||||
version: 1,
|
||||
expiresAt: 1
|
||||
}
|
||||
},
|
||||
writeBundle,
|
||||
lifecycle
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
expect(writeBundle).toHaveBeenCalledOnce()
|
||||
expect(storedOverlays).toEqual([expect.objectContaining({ hostId: relayHost.id })])
|
||||
})
|
||||
|
||||
it('rewrites only the endpoint when name is omitted', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(stored))
|
||||
const lifecycle = beginHostEndpointPublicationLifecycle('host-1')
|
||||
|
||||
await updateHostNameAndEndpoint('host-1', { endpoint: 'ws://192.168.1.10:6768' })
|
||||
|
||||
@@ -87,6 +173,9 @@ describe('updateHostNameAndEndpoint', () => {
|
||||
'orca:hosts',
|
||||
JSON.stringify([{ ...stored[0], endpoint: 'ws://192.168.1.10:6768' }, stored[1]])
|
||||
)
|
||||
expect(getHostEndpointPublicationLifecycle('host-1').endpointRevision).toBe(
|
||||
lifecycle.endpointRevision + 1
|
||||
)
|
||||
})
|
||||
|
||||
it('throws and writes nothing when the host is missing', async () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
|
||||
import {
|
||||
recordDurableHostIdentity,
|
||||
recordHostEndpointMutation,
|
||||
recordHostProfileMutation,
|
||||
resetHostProfilePublicationForTests,
|
||||
retireHostProfilePublication,
|
||||
@@ -304,7 +305,11 @@ export async function updateHostNameAndEndpoint(
|
||||
? undefined
|
||||
: () => updateMobileRelayHostOverlayDirectEndpoint(hostId, updates.endpoint!)
|
||||
)
|
||||
recordHostProfileMutation(hostId)
|
||||
if (updates.endpoint === undefined) {
|
||||
recordHostProfileMutation(hostId)
|
||||
} else {
|
||||
recordHostEndpointMutation(hostId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export function startMobileEndpointLifecycle(
|
||||
return (
|
||||
!stopped &&
|
||||
current.generation === endpointLifecycle.generation &&
|
||||
current.profileRevision === endpointLifecycle.profileRevision
|
||||
current.endpointRevision === endpointLifecycle.endpointRevision
|
||||
)
|
||||
}).catch(() => {})
|
||||
owner = createSupervisor(
|
||||
|
||||
@@ -442,7 +442,7 @@ describe('mobile rpc-client request deadline', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await settled
|
||||
expect(outcome).toBe('Request timed out: worktree.list')
|
||||
expect(outcome).toBe('Request timed out: worktree.ps')
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('stable logical RPC client', () => {
|
||||
|
||||
await expect(verifying).resolves.toBeUndefined()
|
||||
expect(nextSession.sendRequest).toHaveBeenCalledWith(
|
||||
'worktree.list',
|
||||
'worktree.ps',
|
||||
{ limit: 1 },
|
||||
{
|
||||
timeoutMs: expect.any(Number),
|
||||
|
||||
@@ -416,7 +416,6 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'worktree.activate',
|
||||
'worktree.create',
|
||||
'worktree.forceDeleteBranch',
|
||||
'worktree.list',
|
||||
'worktree.prefetchCreateBase',
|
||||
'worktree.ps',
|
||||
'worktree.show',
|
||||
|
||||
Reference in New Issue
Block a user