diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index 9d8e3d1a51e..ff3609bc41b 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -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), diff --git a/mobile/src/transport/existing-host-relay-routing.ts b/mobile/src/transport/existing-host-relay-routing.ts index 0ae4a3afc76..fb8cbd93923 100644 --- a/mobile/src/transport/existing-host-relay-routing.ts +++ b/mobile/src/transport/existing-host-relay-routing.ts @@ -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') } diff --git a/mobile/src/transport/force-reconnect-rpc-health.test.ts b/mobile/src/transport/force-reconnect-rpc-health.test.ts index 398385d3210..e1584c05ea6 100644 --- a/mobile/src/transport/force-reconnect-rpc-health.test.ts +++ b/mobile/src/transport/force-reconnect-rpc-health.test.ts @@ -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') diff --git a/mobile/src/transport/force-reconnect-rpc-health.ts b/mobile/src/transport/force-reconnect-rpc-health.ts index dc857efad97..56632401549 100644 --- a/mobile/src/transport/force-reconnect-rpc-health.ts +++ b/mobile/src/transport/force-reconnect-rpc-health.ts @@ -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') } diff --git a/mobile/src/transport/host-profile-publication.ts b/mobile/src/transport/host-profile-publication.ts index 783945e4a07..22a7e3eed23 100644 --- a/mobile/src/transport/host-profile-publication.ts +++ b/mobile/src/transport/host-profile-publication.ts @@ -2,12 +2,13 @@ import type { HostProfile } from './types' const pendingByHost = new Map>() const revisionByHost = new Map() +const endpointRevisionByHost = new Map() const endpointGenerationByHost = new Map() const publishedIdentityByHost = new Map>() 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 | undefined { @@ -34,7 +40,7 @@ export function recordDurableHostIdentity( } export function retireHostProfilePublication(hostId: string): Promise | 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() } diff --git a/mobile/src/transport/host-store-endpoint.test.ts b/mobile/src/transport/host-store-endpoint.test.ts index c7c66a62334..10e4a9d04ff 100644 --- a/mobile/src/transport/host-store-endpoint.test.ts +++ b/mobile/src/transport/host-store-endpoint.test.ts @@ -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((resolve) => { + markRenameStarted = resolve + }) + const renamePending = new Promise((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 () => { diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index b0ff35e3620..997f74ed024 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -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) + } }) } diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index 195948b238c..af36b875bc9 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -84,7 +84,7 @@ export function startMobileEndpointLifecycle( return ( !stopped && current.generation === endpointLifecycle.generation && - current.profileRevision === endpointLifecycle.profileRevision + current.endpointRevision === endpointLifecycle.endpointRevision ) }).catch(() => {}) owner = createSupervisor( diff --git a/mobile/src/transport/rpc-client-request-deadline.test.ts b/mobile/src/transport/rpc-client-request-deadline.test.ts index 7d029dced6e..d2fd4705384 100644 --- a/mobile/src/transport/rpc-client-request-deadline.test.ts +++ b/mobile/src/transport/rpc-client-request-deadline.test.ts @@ -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() }) diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index bff1daa46a5..7cfb45b2104 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -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), diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 3a13a32a25b..48903b5a1fe 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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',