fix(mobile): close reconnect review gaps

This commit is contained in:
Brennan Benson
2026-08-01 14:59:55 -07:00
parent 915b3e5a6a
commit 09e264600b
11 changed files with 176 additions and 18 deletions
+2 -2
View File
@@ -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 () => {
+6 -1
View File
@@ -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),
-1
View File
@@ -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',