From c5c1477ad4daabde26c6f1746beb22b2fd4e5475 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 25 Sep 2026 19:12:10 -0400 Subject: [PATCH] refactor(mobile): relay learners own only relay routing Follow-up to the field-ownership split. The supervisor keeps its host read-only and holds the relay in a small owner that persists moves through setRelayRouting; the direct upgrade returns { relay, bundle } and the lifecycle composes the profile once. With one direct endpoint left, the probe takes a bound openDirect and the lifecycle computes the direct path once. One name per writer: setRelayRouting and savePairedHost are also the dependency keys, and the removed-host error is RelayRoutingHostRemovedError. The census now counts real value imports of savePairedHost, not mentions. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> Co-authored-by: Neil --- .../pairing-journal-mount-adapters.ts | 4 +- .../relay-credential-mount-adapters.ts | 4 +- .../src/transport/host-store-endpoint.test.ts | 4 +- mobile/src/transport/host-store.test.ts | 4 +- mobile/src/transport/host-store.ts | 4 +- .../mobile-direct-endpoint-probe.test.ts | 70 +++++++------------ .../transport/mobile-direct-endpoint-probe.ts | 10 ++- .../transport/mobile-direct-return-probe.ts | 12 ++-- ...obile-endpoint-lifecycle-host-edit.test.ts | 34 ++++++++- .../transport/mobile-endpoint-lifecycle.ts | 8 ++- .../mobile-endpoint-supervisor-contract.ts | 6 +- ...-endpoint-supervisor-rotation-stop.test.ts | 58 --------------- .../mobile-endpoint-supervisor-support.ts | 36 +++++++--- .../mobile-endpoint-supervisor-test-fakes.ts | 3 +- .../mobile-endpoint-supervisor.test.ts | 4 +- .../transport/mobile-endpoint-supervisor.ts | 19 +++-- .../src/transport/mobile-relay-connect-url.ts | 4 -- ...le-relay-direct-upgrade-controller.test.ts | 17 ++--- .../mobile-relay-direct-upgrade.test.ts | 20 +++--- .../transport/mobile-relay-direct-upgrade.ts | 15 ++-- .../src/transport/mobile-relay-e2ee-link.ts | 4 +- .../mobile-relay-host-overlay-store.test.ts | 8 --- .../mobile-relay-host-overlay-store.ts | 36 ++++------ .../transport/mobile-relay-host-overlay.ts | 10 ++- .../mobile-relay-pairing-recovery.test.ts | 6 +- .../mobile-relay-pairing-recovery.ts | 6 +- .../transport/mobile-relay-physical-client.ts | 4 +- .../mobile-relay-runtime-failover.test.ts | 3 +- .../paired-host-writer-census.test.ts | 14 ++-- .../pre-profile-pairing-coordinator.test.ts | 18 ++--- .../pre-profile-pairing-coordinator.ts | 10 +-- 31 files changed, 203 insertions(+), 252 deletions(-) delete mode 100644 mobile/src/transport/mobile-endpoint-supervisor-rotation-stop.test.ts diff --git a/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts index 2567758ac5c..26cd6d0a0d3 100644 --- a/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts @@ -51,7 +51,7 @@ export function pairingJournalMountAdapters( effect('bundle-written', { version: written.current.version }) }, loadHosts: async () => [], - saveHost: async () => { + savePairedHost: async () => { effect('host-saved', HOST_ID) }, connectRelay: () => candidateClient(client, effect, 'relay'), @@ -95,7 +95,7 @@ export function pairingJournalMountAdapters( connectRelay: () => candidateClient(client, effect, 'relay'), resolveInviteDirector: async () => pairingRelay(), resolveHostIdentity: async () => ({ id: HOST_ID, name: 'Fixture host' }), - saveHost: async (host: { relay?: { relayHostId: string } }) => { + savePairedHost: async (host: { relay?: { relayHostId: string } }) => { savedHost = host.relay?.relayHostId ?? 'direct-only' effect('host-saved', savedHost) }, diff --git a/mobile/src/test-support/rpc-recording/adapters/relay-credential-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/relay-credential-mount-adapters.ts index b7fadb2265f..75a11afadf9 100644 --- a/mobile/src/test-support/rpc-recording/adapters/relay-credential-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/relay-credential-mount-adapters.ts @@ -106,7 +106,7 @@ export function relayCredentialMountAdapters( writeBundle: async (written: { current: { version: number } }) => { effect('bundle-written', { version: written.current.version }) }, - saveRelayRouting: async () => { + setRelayRouting: async () => { effect('host-saved', HOST_ID) }, deleteBundle: async () => { @@ -116,7 +116,7 @@ export function relayCredentialMountAdapters( }) started.then( (result) => { - outcome = result === null ? 'declined' : result.host.relay?.relayHostId + outcome = result === null ? 'declined' : result.relay.relayHostId }, (error: unknown) => { outcome = `failed: ${error instanceof Error ? error.message : String(error)}` diff --git a/mobile/src/transport/host-store-endpoint.test.ts b/mobile/src/transport/host-store-endpoint.test.ts index 095c79e885f..c3d11bc2dc1 100644 --- a/mobile/src/transport/host-store-endpoint.test.ts +++ b/mobile/src/transport/host-store-endpoint.test.ts @@ -3,7 +3,7 @@ import * as SecureStore from 'expo-secure-store' import { beforeEach, describe, expect, it, vi } from 'vitest' import { loadHosts, - MobileRelayUpgradeHostRemovedError, + RelayRoutingHostRemovedError, resetHostStoreForTests, setRelayRouting, updateHostNameAndEndpoint @@ -211,7 +211,7 @@ describe('relay routing after a host edit', () => { storage.set(OVERLAY_KEY, '[]') await expect(setRelayRouting('host-1', relay)).rejects.toBeInstanceOf( - MobileRelayUpgradeHostRemovedError + RelayRoutingHostRemovedError ) expect(writesTo(OVERLAY_KEY)).toEqual([]) diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index b3d2d858079..60269c01d5e 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -44,7 +44,7 @@ vi.mock('./host-credential-cleanup', () => ({ import { loadHostCatalog, loadHosts, - MobileRelayUpgradeHostRemovedError, + RelayRoutingHostRemovedError, removeHost, resolvePairingHostIdentity, resetHostStoreForTests, @@ -564,7 +564,7 @@ describe('host-store list mutations', () => { storedHostsRaw = JSON.stringify([HOST_TWO]) await expect(setRelayRouting(HOST_ONE.id, HOST_ONE_RELAY)).rejects.toBeInstanceOf( - MobileRelayUpgradeHostRemovedError + RelayRoutingHostRemovedError ) expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 6c599aa1cf6..388f7579016 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -149,7 +149,7 @@ function removeOrphanOverlayIfUnpaired(hostId: string): Promise { // The page's host-store sibling keeps its own no-op, so only the native store reaches persistence. export { updateHostDescriptor } from './host-descriptor-persistence' -export class MobileRelayUpgradeHostRemovedError extends Error {} +export class RelayRoutingHostRemovedError extends Error {} /** * Relay routing learned after pairing (director re-resolution, rotation, direct upgrade). Takes no @@ -161,7 +161,7 @@ export async function setRelayRouting(hostId: string, relay: MobileRelayEndpoint const hosts = await readStoredHostProfilesForMutation() if (!hosts.some(({ id }) => id === hostId)) { // Why: an in-flight relay learner must not resurrect a host the user removed. - throw new MobileRelayUpgradeHostRemovedError('mobile relay host was removed') + throw new RelayRoutingHostRemovedError('mobile relay host was removed') } wrote = await saveMobileRelayHostRouting(hostId, relay) }) diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.test.ts b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts index 2ed5a509f28..044c06e2e26 100644 --- a/mobile/src/transport/mobile-direct-endpoint-probe.test.ts +++ b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from './rpc-client' -import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' -import type { ConnectionState, HostProfile, RpcResponse } from './types' +import { + directPathForEndpoint, + openAuthenticatedDirectEndpoint +} from './mobile-direct-endpoint-probe' +import type { ConnectionState, RpcResponse } from './types' class FakeClient implements RpcClient { readonly sendRequest = vi.fn(async (): Promise => ({ @@ -34,33 +37,14 @@ class FakeClient implements RpcClient { } } -const host: HostProfile = { - id: 'host-1', - name: 'Blue Whale', - endpoint: 'ws://100.64.0.2:6768', - deviceToken: 'device-token', - publicKeyB64: 'A'.repeat(44), - lastConnected: 1 -} - describe('mobile direct endpoint probe', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) - it('dials only the saved endpoint and reports its path', async () => { - const openDirect = vi.fn(() => { - const client = new FakeClient('connecting') - setTimeout(() => client.publishState('connected'), 100) - return client - }) - - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) - await vi.advanceTimersByTimeAsync(100) - const result = await probing - - expect(openDirect.mock.calls).toEqual([[host.endpoint]]) - expect(result?.path).toBe('tailscale') - expect(result?.client.close).not.toHaveBeenCalled() + it('classifies a tailnet endpoint apart from a LAN one', () => { + expect(directPathForEndpoint('ws://100.64.0.2:6768')).toBe('tailscale') + expect(directPathForEndpoint('wss://desk.tail1234.ts.net:6768')).toBe('tailscale') + expect(directPathForEndpoint('ws://192.168.1.10:6768')).toBe('lan') }) it('fails a whole dead LAN in seconds instead of holding the 12s bound', async () => { @@ -75,7 +59,7 @@ describe('mobile direct endpoint probe', () => { return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(20) await vi.advanceTimersByTimeAsync(2_000) await expect(probing).resolves.toBeNull() @@ -89,41 +73,37 @@ describe('mobile direct endpoint probe', () => { it('rides out one access-point flap that the first redial recovers', async () => { // 'reconnecting' is published on any socket close, so a single RST on the first // dial must not book a direct failure and its 60s cooldown. - const openDirect = vi.fn((endpoint: string) => { + const openDirect = vi.fn(() => { const client = new FakeClient('connecting') - if (endpoint.includes('100.64.0.2')) { - setTimeout(() => client.publishState('reconnecting'), 20) - setTimeout(() => client.publishState('connected'), 600) - } + setTimeout(() => client.publishState('reconnecting'), 20) + setTimeout(() => client.publishState('connected'), 600) return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(600) const result = await probing - expect(result?.path).toBe('tailscale') - expect(result?.client.close).not.toHaveBeenCalled() + expect(result).not.toBeNull() + expect(result?.close).not.toHaveBeenCalled() }) it('extends the grace once when the redial reaches a handshake', async () => { // The redial fires at 500ms, but 'connected' waits on the Noise handshake and a // capability RPC, so real work needs more than one grace window. - const openDirect = vi.fn((endpoint: string) => { + const openDirect = vi.fn(() => { const client = new FakeClient('connecting') - if (endpoint.includes('100.64.0.2')) { - setTimeout(() => client.publishState('reconnecting'), 20) - setTimeout(() => client.publishState('handshaking'), 1_500) - // Past the first grace window: only the re-arm keeps this probe alive. - setTimeout(() => client.publishState('connected'), 3_000) - } + setTimeout(() => client.publishState('reconnecting'), 20) + setTimeout(() => client.publishState('handshaking'), 1_500) + // Past the first grace window: only the re-arm keeps this probe alive. + setTimeout(() => client.publishState('connected'), 3_000) return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(3_000) - expect((await probing)?.path).toBe('tailscale') + expect(await probing).not.toBeNull() }) it('fails a handshake that stalls, one grace after it started', async () => { @@ -136,7 +116,7 @@ describe('mobile direct endpoint probe', () => { return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(3_499) let settled = false void probing.then(() => { @@ -157,7 +137,7 @@ describe('mobile direct endpoint probe', () => { return client }) - const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + const probing = openAuthenticatedDirectEndpoint(openDirect, 12_000) await vi.advanceTimersByTimeAsync(2_019) let settled = false void probing.then(() => { diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.ts b/mobile/src/transport/mobile-direct-endpoint-probe.ts index 58dc731434d..58ce9c74a0a 100644 --- a/mobile/src/transport/mobile-direct-endpoint-probe.ts +++ b/mobile/src/transport/mobile-direct-endpoint-probe.ts @@ -1,6 +1,5 @@ import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' -import type { HostProfile } from './types' export function directPathForEndpoint(endpoint: string): Exclude { try { @@ -85,17 +84,16 @@ function waitForAuthenticatedSession( } export async function openAuthenticatedDirectEndpoint( - host: HostProfile, - openDirect: (endpoint: string) => RpcClient, + openDirect: () => RpcClient, timeoutMs: number, signal?: AbortSignal -): Promise<{ client: RpcClient; path: Exclude } | null> { +): Promise { if (signal?.aborted) { return null } let client: RpcClient try { - client = openDirect(host.endpoint) + client = openDirect() } catch { return null } @@ -109,5 +107,5 @@ export async function openAuthenticatedDirectEndpoint( client.close() return null } - return { client, path: directPathForEndpoint(host.endpoint) } + return client } diff --git a/mobile/src/transport/mobile-direct-return-probe.ts b/mobile/src/transport/mobile-direct-return-probe.ts index c3b3464a3ba..3552805f7ee 100644 --- a/mobile/src/transport/mobile-direct-return-probe.ts +++ b/mobile/src/transport/mobile-direct-return-probe.ts @@ -2,7 +2,6 @@ import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' import type { RpcClient } from './rpc-client' import type { ScheduleTimer } from './timer-scheduler' -import type { HostProfile } from './types' import type { MobileConnectionPath } from './stable-logical-rpc-client' const DIRECT_PROBE_INTERVAL_MS = 15_000 @@ -20,11 +19,11 @@ export class DirectReturnProbe { now: () => number setTimer: ScheduleTimer clearTimer: typeof clearTimeout - openDirect: (endpoint: string) => RpcClient + openDirect: () => RpcClient + directPath: Exclude }, private readonly hooks: { hysteresis: MobileEndpointHysteresis - host: () => HostProfile canSchedule: () => boolean canAttempt: () => boolean beginOperation: () => void @@ -75,7 +74,6 @@ export class DirectReturnProbe { let successful: Awaited> = null try { successful = await openAuthenticatedDirectEndpoint( - this.hooks.host(), this.deps.openDirect, 12_000, controller.signal @@ -88,14 +86,14 @@ export class DirectReturnProbe { return } if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) { - successful.client.close() + successful.close() return } const candidate = successful // Migration owns the candidate, including closing it if cutover is canceled. successful = null try { - await this.hooks.migrate(candidate.client, candidate.path, () => this.stopped) + await this.hooks.migrate(candidate, this.deps.directPath, () => this.stopped) } catch (error) { if (this.stopped) { return @@ -109,7 +107,7 @@ export class DirectReturnProbe { await this.hooks.onDirectMigrated() } finally { this.activeProbe = null - successful?.client.close() + successful?.close() // Why: a relay drop or backoff timer can arrive while the probe owns the // operation mutex; afterProbe releases it and replays deferred recovery. this.hooks.afterProbe() diff --git a/mobile/src/transport/mobile-endpoint-lifecycle-host-edit.test.ts b/mobile/src/transport/mobile-endpoint-lifecycle-host-edit.test.ts index 90ad0a0abe1..b676e979188 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle-host-edit.test.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle-host-edit.test.ts @@ -86,6 +86,10 @@ async function startWithPendingResolution(): Promise<{ return { logical, lifecycle, settle: (value) => settle(value) } } +function overlayWrites(): number { + return asyncStorageMock.setItem.mock.calls.filter(([key]) => key === OVERLAY_KEY).length +} + async function expectEditKept(expectedRelay: MobileRelayEndpoint): Promise { const [saved] = await loadHosts() expect(saved).toMatchObject({ @@ -162,8 +166,6 @@ describe('mobile endpoint lifecycle host edits', () => { mockCredentialRotation(logical) const lifecycle = startMobileEndpointLifecycle(logical, host, () => {}) await updateHostNameAndEndpoint(host.id, { personalName: 'Renamed', endpoint: EDITED_ENDPOINT }) - const overlayWrites = (): number => - asyncStorageMock.setItem.mock.calls.filter(([key]) => key === OVERLAY_KEY).length logical.publishState('connected') await vi.waitFor(() => expect(overlayWrites()).toBe(1)) @@ -172,6 +174,34 @@ describe('mobile endpoint lifecycle host edits', () => { lifecycle.stop() }) + it('does not persist relay routing from a rotation that finishes after stop', async () => { + readBundleMock.mockResolvedValue({ + ...bundle, + current: { ...bundle.current, expiresAt: Date.now() + 60_000 } + }) + let finishCredentialWrite: () => void = () => {} + writeBundleMock.mockResolvedValueOnce().mockReturnValueOnce( + new Promise((resolve) => { + finishCredentialWrite = resolve + }) + ) + const logical = new FakeLogicalClient('connected', 'lan') + mockCredentialRotation(logical) + const lifecycle = startMobileEndpointLifecycle(logical, host, () => {}) + await updateHostNameAndEndpoint(host.id, { personalName: 'Renamed', endpoint: EDITED_ENDPOINT }) + + logical.publishState('connected') + await vi.waitFor(() => expect(writeBundleMock).toHaveBeenCalledTimes(2)) + lifecycle.stop() + finishCredentialWrite() + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The rotated bundle itself stays durable; only the stale relay routing write is skipped. + expect(writeBundleMock).toHaveBeenCalledTimes(2) + expect(overlayWrites()).toBe(0) + await expectEditKept(relay) + }) + it('keeps an edit made while a direct-only host was being upgraded to relay', async () => { storage.set(OVERLAY_KEY, '[]') const { relay: _relay, ...directHost } = host diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index 51ea91fa6ea..79ab7cdbbff 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -10,6 +10,7 @@ import { } from './mobile-relay-credential-bundle' import { setRelayRouting } from './host-store' import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade' +import { directPathForEndpoint } from './mobile-direct-endpoint-probe' import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller' import { defaultCancelTimer, defaultScheduleTimer } from './timer-scheduler' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' @@ -55,7 +56,7 @@ export function startMobileEndpointLifecycle( host, dependencies: { randomBytes: ExpoCrypto.getRandomBytes } }), - onUpgraded: ({ host }) => startSupervisor(host) + onUpgraded: ({ relay }) => startSupervisor({ ...initialHost, relay }) }) void owner.start() } @@ -86,7 +87,8 @@ function createSupervisor( onLog: ConnectionLogSink ): MobileEndpointSupervisor { return new MobileEndpointSupervisor(logical, host, { - openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }), + openDirect: () => connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }), + directPath: directPathForEndpoint(host.endpoint), openRelay: (relay, credential, confirmReqId, onHostCloseReason) => connectMobileRelayRpcSession({ relay, @@ -101,7 +103,7 @@ function createSupervisor( resolveRelay: resolveMobileRelayEndpoint, readBundle: readMobileRelayCredentialBundle, writeBundle: writeMobileRelayCredentialBundle, - saveRelayRouting: setRelayRouting, + setRelayRouting, onLog, now: Date.now, randomBytes: ExpoCrypto.getRandomBytes, diff --git a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts index 7bea65d05b9..81620c54ece 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts @@ -4,11 +4,13 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ScheduleTimer } from './timer-scheduler' import type { ConnectionLogSink } from './types' export type MobileEndpointSupervisorDependencies = { - openDirect: (endpoint: string) => RpcClient + openDirect: () => RpcClient + directPath: Exclude openRelay: ( relay: MobileRelayEndpoint, credential: { token: string; version: number }, @@ -18,7 +20,7 @@ export type MobileEndpointSupervisorDependencies = { resolveRelay: typeof resolveMobileRelayEndpoint readBundle: (hostId: string) => Promise writeBundle: (bundle: MobileRelayCredentialBundle) => Promise - saveRelayRouting: (hostId: string, relay: MobileRelayEndpoint) => Promise + setRelayRouting: (hostId: string, relay: MobileRelayEndpoint) => Promise now: () => number randomBytes: (length: number) => Uint8Array setTimer: ScheduleTimer diff --git a/mobile/src/transport/mobile-endpoint-supervisor-rotation-stop.test.ts b/mobile/src/transport/mobile-endpoint-supervisor-rotation-stop.test.ts deleted file mode 100644 index fb109c87ab4..00000000000 --- a/mobile/src/transport/mobile-endpoint-supervisor-rotation-stop.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' -import { - bundle, - dependencies, - FakeLogicalClient, - host, - mockCredentialRotation -} from './mobile-endpoint-supervisor-test-fakes' -import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' - -vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) -vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) -vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) - -describe('mobile endpoint supervisor credential rotation after stop', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-07-13T12:00:00Z')) - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('does not persist relay routing from a rotation that finishes after stop', async () => { - const logical = new FakeLogicalClient('connected', 'lan') - let finishCredentialWrite: (() => void) | undefined - const credentialWritePending = new Promise((resolve) => { - finishCredentialWrite = resolve - }) - const writeBundle = vi - .fn<(value: MobileRelayCredentialBundle) => Promise>() - .mockResolvedValue() - .mockResolvedValueOnce() - .mockReturnValueOnce(credentialWritePending) - mockCredentialRotation(logical) - const deps = dependencies({ - readBundle: vi.fn(async () => ({ - ...bundle, - current: { ...bundle.current, expiresAt: Date.now() + 60_000 } - })), - writeBundle - }) - const supervisor = new MobileEndpointSupervisor(logical, host, deps) - - await supervisor.start() - logical.publishState('connected') - await vi.waitFor(() => expect(writeBundle).toHaveBeenCalledTimes(2)) - supervisor.stop() - finishCredentialWrite?.() - await vi.advanceTimersByTimeAsync(0) - - // The rotated bundle itself stays durable; only the stale relay routing write is skipped. - expect(writeBundle).toHaveBeenCalledTimes(2) - expect(deps.saveRelayRouting).not.toHaveBeenCalled() - }) -}) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-support.ts b/mobile/src/transport/mobile-endpoint-supervisor-support.ts index 32ffa497fc3..ec4edede5d7 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-support.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-support.ts @@ -81,18 +81,32 @@ export function isDirectorResolutionFailure(error: Error): boolean { ) } -// Why: a stopped supervisor's host may be removed or re-paired; its successor owns routing. -export async function adoptRelayRouting( - host: HostProfile, - relay: MobileRelayEndpoint, - dependencies: Pick, - stopped: boolean -): Promise { - if (stopped) { - return host +/** The relay a supervisor dials; a learned move is persisted as routing, never as a profile. */ +export class SupervisedRelayRouting { + private relay: MobileRelayEndpoint | undefined + private readonly hostId: string + + constructor( + host: Pick, + private readonly dependencies: Pick, + private readonly isStopped: () => boolean + ) { + this.relay = host.relay + this.hostId = host.id + } + + current(): MobileRelayEndpoint | undefined { + return this.relay + } + + async adopt(relay: MobileRelayEndpoint): Promise { + // Why: a stopped supervisor's host may be removed or re-paired; its successor owns routing. + if (this.isStopped()) { + return + } + await this.dependencies.setRelayRouting(this.hostId, relay) + this.relay = relay } - await dependencies.saveRelayRouting(host.id, relay) - return { ...host, relay } } export function encodeBase64Url(value: Uint8Array): string { diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index 6f15087e3c8..70bf1f3aae2 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -210,7 +210,8 @@ export function dependencies( resolveRelay: vi.fn(async ({ relay }) => relay), readBundle: vi.fn(async () => bundle), writeBundle: vi.fn(async () => {}), - saveRelayRouting: vi.fn(async () => {}), + setRelayRouting: vi.fn(async () => {}), + directPath: 'lan', now: Date.now, randomBytes: (length) => new Uint8Array(length).fill(1), setTimer: defaultScheduleTimer, diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 36f11912ba3..578de09f7ca 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -191,7 +191,7 @@ describe('mobile endpoint supervisor', () => { expect.any(String), expect.any(Function) ) - expect(deps.saveRelayRouting).toHaveBeenCalledWith(host.id, resolved) + expect(deps.setRelayRouting).toHaveBeenCalledWith(host.id, resolved) supervisor.stop() }) @@ -664,7 +664,7 @@ describe('mobile endpoint supervisor', () => { await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce()) supervisor.setForeground(false) finishResolve?.(relay) - await vi.waitFor(() => expect(deps.saveRelayRouting).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(deps.setRelayRouting).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(0) expect(openRelay).toHaveBeenCalledTimes(2) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index b947b383871..e2ac977b6b1 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -4,8 +4,8 @@ import { RelayReconnectController } from './mobile-relay-reconnect-controller' import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer' import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' import { - adoptRelayRouting, liveRelayLeaseExpiry, + SupervisedRelayRouting, suspendRelayIfStillConnected } from './mobile-endpoint-supervisor-support' import { selectDialableRelayCredentials } from './mobile-relay-credential-selection' @@ -36,6 +36,7 @@ const FAILURE_COOLDOWN_MS = 60_000 export class MobileEndpointSupervisor { private bundle: MobileRelayCredentialBundle | null = null + private readonly relayRouting: SupervisedRelayRouting private stopped = false private operationInFlight = false private pendingReplace = false @@ -54,9 +55,10 @@ export class MobileEndpointSupervisor { constructor( private readonly logical: StableLogicalRpcClient, - private host: HostProfile, + private readonly host: HostProfile, private readonly dependencies: MobileEndpointSupervisorDependencies ) { + this.relayRouting = new SupervisedRelayRouting(host, dependencies, () => this.stopped) this.hysteresis = new MobileEndpointHysteresis(dependencies.now(), { directSuccessesRequired: 3, directObservationMs: DIRECT_OBSERVATION_MS, @@ -93,11 +95,9 @@ export class MobileEndpointSupervisor { writeBundle: dependencies.writeBundle, isActive: () => this.isActive(), isForeground: () => this.backgroundGrace.isForeground(), - relay: () => this.host.relay, + relay: () => this.relayRouting.current(), resolveRelay: dependencies.resolveRelay, - persistResolvedRelay: async (resolved) => { - this.host = await adoptRelayRouting(this.host, resolved, dependencies, this.stopped) - }, + persistResolvedRelay: (resolved) => this.relayRouting.adopt(resolved), bundle: () => this.bundle, adoptBundle: (bundle) => (this.bundle = bundle), recordMigration: () => { @@ -116,7 +116,6 @@ export class MobileEndpointSupervisor { }) this.directProbe = new DirectReturnProbe(dependencies, { hysteresis: this.hysteresis, - host: () => this.host, canSchedule: () => this.isActive() && this.logical.getActivePath() === 'relay', canAttempt: () => this.isActive() && !this.operationInFlight, beginOperation: () => (this.operationInFlight = true), @@ -149,7 +148,7 @@ export class MobileEndpointSupervisor { async start(): Promise { this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null) - if (this.stopped || !this.host.relay) { + if (this.stopped || !this.relayRouting.current()) { return } if (!this.bundle) { @@ -211,7 +210,7 @@ export class MobileEndpointSupervisor { // shared cooldown and any session left stale-'connected' by a half-open socket // comes down; lease rotation clears it because armRetry owns its own retry. private async recoverRelay(forceReplacement = false, ownsRecovery = false): Promise { - if (!this.isActive() || !this.host.relay) { + if (!this.isActive() || !this.relayRouting.current()) { return } if (this.operationInFlight) { @@ -325,7 +324,7 @@ export class MobileEndpointSupervisor { this.bundle = result.bundle // Why: a scheduled rotation can finish after the old credential enters the rejection gate. credentialRefreshed = true - this.host = await adoptRelayRouting(this.host, result.relay, this.dependencies, this.stopped) + await this.relayRouting.adopt(result.relay) } catch { // Why: pending material remains durable; the next authenticated direct // opportunity must reconcile it before creating another install key. diff --git a/mobile/src/transport/mobile-relay-connect-url.ts b/mobile/src/transport/mobile-relay-connect-url.ts index 19a1c46df86..5f83812764b 100644 --- a/mobile/src/transport/mobile-relay-connect-url.ts +++ b/mobile/src/transport/mobile-relay-connect-url.ts @@ -5,7 +5,3 @@ export function relayConnectWebSocketUrl(baseUrl: string, relayHostId: string): url.pathname = `/v1/connect/${encodeURIComponent(relayHostId)}` return url.toString() } - -export function relayWebSocketUrl(relay: { cellUrl: string; relayHostId: string }): string { - return relayConnectWebSocketUrl(relay.cellUrl, relay.relayHostId) -} diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts index 14eef5bd5f8..0d99c7d9429 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts @@ -14,16 +14,13 @@ const directHost: HostProfile = { } const upgraded = { - host: { - ...directHost, - relay: { - v: 1 as const, - directorUrl: 'https://relay-staging.onorca.dev', - cellUrl: 'https://c1.relay-staging.onorca.dev', - assignmentEpoch: 4, - relayHostId: 'AbCdEf0123_-xyZ9', - e2eeFraming: 2 as const - } + relay: { + v: 1 as const, + directorUrl: 'https://relay-staging.onorca.dev', + cellUrl: 'https://c1.relay-staging.onorca.dev', + assignmentEpoch: 4, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const }, bundle: { v: 1 as const, diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts index 84ac2e63793..d76e887b299 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade.test.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' -import { MobileRelayUpgradeHostRemovedError } from './host-store' +import { RelayRoutingHostRemovedError } from './host-store' import { createMobileRelayDirectUpgradeJournal, type MobileRelayDirectUpgradeJournal @@ -64,7 +64,7 @@ function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) { }), writeBundle: vi.fn(async () => {}), deleteBundle: vi.fn(async () => {}), - saveRelayRouting: vi.fn(async () => {}), + setRelayRouting: vi.fn(async () => {}), randomBytes: (length: number) => new Uint8Array(length).fill(7) } } @@ -113,9 +113,9 @@ describe('existing direct pairing relay upgrade', () => { reqId: journal!.reqId, newResumeTokenHash: journal!.pendingResumeTokenHash }) - expect(deps.writeBundle).toHaveBeenCalledBefore(deps.saveRelayRouting) - expect(deps.saveRelayRouting).toHaveBeenCalledWith(host.id, relay) - expect(result?.host).toEqual({ ...host, relay }) + expect(deps.writeBundle).toHaveBeenCalledBefore(deps.setRelayRouting) + expect(deps.setRelayRouting).toHaveBeenCalledWith(host.id, relay) + expect(result?.relay).toEqual(relay) expect(deps.clearJournal).toHaveBeenCalledWith(host.id) }) @@ -154,7 +154,7 @@ describe('existing direct pairing relay upgrade', () => { await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull() expect(deps.clearJournal).toHaveBeenCalledWith(host.id) expect(deps.writeBundle).not.toHaveBeenCalled() - expect(deps.saveRelayRouting).not.toHaveBeenCalled() + expect(deps.setRelayRouting).not.toHaveBeenCalled() }) // Why 'forbidden': a desktop that predates pairing.getEndpoints has it on neither its mobile @@ -177,7 +177,7 @@ describe('existing direct pairing relay upgrade', () => { await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull() expect(deps.clearJournal).toHaveBeenCalledWith(host.id) expect(deps.writeBundle).not.toHaveBeenCalled() - expect(deps.saveRelayRouting).not.toHaveBeenCalled() + expect(deps.setRelayRouting).not.toHaveBeenCalled() }) it('retains the durable journal when relay registration is temporarily unavailable', async () => { @@ -197,8 +197,8 @@ describe('existing direct pairing relay upgrade', () => { ) const committed = installed(journal) const deps = dependencies(journal) - deps.saveRelayRouting.mockRejectedValue( - new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + deps.setRelayRouting.mockRejectedValue( + new RelayRoutingHostRemovedError('mobile relay upgrade host was removed') ) const client = clientWith([ success({ @@ -210,7 +210,7 @@ describe('existing direct pairing relay upgrade', () => { await expect( upgradeDirectMobileRelay({ client, host, dependencies: deps }) - ).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError) + ).rejects.toBeInstanceOf(RelayRoutingHostRemovedError) expect(deps.deleteBundle).toHaveBeenCalledWith(host.id) expect(deps.clearJournal).toHaveBeenCalledWith(host.id) }) diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.ts b/mobile/src/transport/mobile-relay-direct-upgrade.ts index 5855647ce52..30b2a76e681 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade.ts @@ -1,9 +1,10 @@ import * as ExpoCrypto from 'expo-crypto' import type { DeviceCredentialInstalled, + MobileRelayEndpoint, PairingGetEndpointsResult } from '../../../src/shared/mobile-relay-credential-contract' -import { MobileRelayUpgradeHostRemovedError, setRelayRouting } from './host-store' +import { RelayRoutingHostRemovedError, setRelayRouting } from './host-store' import { MobileRelayCredentialBundleSchema, deleteMobileRelayCredentialBundle, @@ -26,7 +27,7 @@ import type { HostProfile } from './types' import { isPairingRelayRpcUnavailable } from './pairing-relay-rpc-unavailable' export type MobileRelayDirectUpgradeResult = { - host: HostProfile + relay: MobileRelayEndpoint bundle: MobileRelayCredentialBundle } @@ -35,7 +36,7 @@ type Dependencies = { writeJournal: typeof writeMobileRelayDirectUpgradeJournal clearJournal: typeof deleteMobileRelayDirectUpgradeJournal writeBundle: typeof writeMobileRelayCredentialBundle - saveRelayRouting: typeof setRelayRouting + setRelayRouting: typeof setRelayRouting deleteBundle: typeof deleteMobileRelayCredentialBundle randomBytes: (length: number) => Uint8Array } @@ -53,7 +54,7 @@ export async function upgradeDirectMobileRelay(args: { writeJournal: writeMobileRelayDirectUpgradeJournal, clearJournal: deleteMobileRelayDirectUpgradeJournal, writeBundle: writeMobileRelayCredentialBundle, - saveRelayRouting: setRelayRouting, + setRelayRouting, deleteBundle: deleteMobileRelayCredentialBundle, randomBytes: ExpoCrypto.getRandomBytes, ...args.dependencies @@ -120,16 +121,16 @@ async function publishCommitted( // Why: the overlay must never advertise relay without its matching credential. await dependencies.writeBundle(bundle) try { - await dependencies.saveRelayRouting(host.id, endpoints.relay) + await dependencies.setRelayRouting(host.id, endpoints.relay) } catch (error) { - if (error instanceof MobileRelayUpgradeHostRemovedError) { + if (error instanceof RelayRoutingHostRemovedError) { await dependencies.deleteBundle(host.id) await dependencies.clearJournal(host.id) } throw error } await dependencies.clearJournal(host.id) - return { host: { ...host, relay: endpoints.relay }, bundle } + return { relay: endpoints.relay, bundle } } async function getEndpoints( diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index fc6811f96ef..c2f24f71823 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -9,7 +9,7 @@ import { import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' import { websocketPayloadToUint8 } from './websocket-payload-bytes' -import { relayWebSocketUrl } from './mobile-relay-connect-url' +import { relayConnectWebSocketUrl } from './mobile-relay-connect-url' // Native WebSockets normally emit close immediately after error; bound the // missing-close case so a dead socket cannot leave recovery pending forever. @@ -55,7 +55,7 @@ export class MobileRelayE2eeLink { constructor(options: MobileRelayE2eeLinkOptions) { this.options = options this.socket = (options.createSocket ?? ((url) => new WebSocket(url)))( - relayWebSocketUrl(options.endpoint) + relayConnectWebSocketUrl(options.endpoint.cellUrl, options.endpoint.relayHostId) ) const session = MobileE2EEV2ClientSession.create({ desktopPublicKeyB64: options.desktopPublicKeyB64, diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts index 7fbe6814a35..365e3b3e03f 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts @@ -132,14 +132,6 @@ describe('mobile relay host overlay store', () => { expect(JSON.parse(stored!)).toEqual([RELAY_ONLY_OVERLAY]) }) - it('does not rewrite storage when the routing is unchanged', async () => { - stored = JSON.stringify([RELAY_ONLY_OVERLAY]) - - await expect(saveMobileRelayHostRouting('host-1', RELAY)).resolves.toBe(false) - - expect(asyncStorage.setItem).not.toHaveBeenCalled() - }) - it('never overlays or resurrects a host whose legacy base was removed', async () => { stored = JSON.stringify([LEGACY_OVERLAY]) diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts index 69998d80b51..3cb26b0df57 100644 --- a/mobile/src/transport/mobile-relay-host-overlay-store.ts +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -7,7 +7,7 @@ import { } from './mobile-relay-host-overlay' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' -let overlayMutation: Promise = Promise.resolve() +let overlayMutation: Promise = Promise.resolve() function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null { if (raw === null) { @@ -39,15 +39,16 @@ async function readOverlaysForMutation(): Promise { async function mutateOverlays( update: (overlays: MobileRelayHostOverlay[]) => MobileRelayHostOverlay[] -): Promise { +): Promise { const mutation = overlayMutation.then(async () => { const current = await readOverlaysForMutation() const next = update(current) - // Why: direct-only saves commonly have no overlay to remove; avoid a full - // AsyncStorage write when cleanup leaves the durable list unchanged. - if (next !== current) { - await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next)) + // Why: an update handing back the list it read changed nothing; skip the full AsyncStorage write. + if (next === current) { + return false } + await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(next)) + return true }) overlayMutation = mutation.catch(() => {}) return mutation @@ -73,16 +74,14 @@ export async function loadMobileRelayHostOverlayState( } /** Resolves whether storage changed. */ -export async function saveMobileRelayHostRouting( +export function saveMobileRelayHostRouting( hostId: string, relay: MobileRelayEndpoint ): Promise { const validated = toStoredMobileRelayHostOverlay(hostId, relay) - let wrote = false - await mutateOverlays((overlays) => { + return mutateOverlays((overlays) => { const index = overlays.findIndex((overlay) => overlay.hostId === hostId) if (index === -1) { - wrote = true return [...overlays, validated] } // Why: a failing relay loop re-resolves the same cell every retry. Both sides are this @@ -90,30 +89,21 @@ export async function saveMobileRelayHostRouting( if (JSON.stringify(overlays[index]) === JSON.stringify(validated)) { return overlays } - wrote = true const next = overlays.slice() next[index] = validated return next }) - return wrote } export function removeMobileRelayHostOverlay(hostId: string): Promise { return removeMobileRelayHostOverlays([hostId]) } -export function removeMobileRelayHostOverlays(hostIds: readonly string[]): Promise { +export async function removeMobileRelayHostOverlays(hostIds: readonly string[]): Promise { const targets = new Set(hostIds) - let removed = false - return mutateOverlays((overlays) => { - const next = overlays.filter((overlay) => { - if (!targets.has(overlay.hostId)) { - return true - } - removed = true - return false - }) - return removed ? next : overlays + await mutateOverlays((overlays) => { + const next = overlays.filter((overlay) => !targets.has(overlay.hostId)) + return next.length === overlays.length ? overlays : next }) } diff --git a/mobile/src/transport/mobile-relay-host-overlay.ts b/mobile/src/transport/mobile-relay-host-overlay.ts index eea9a51a53d..8076e7a0ad0 100644 --- a/mobile/src/transport/mobile-relay-host-overlay.ts +++ b/mobile/src/transport/mobile-relay-host-overlay.ts @@ -3,7 +3,7 @@ import { MobileRelayEndpointSchema, type MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' -import { relayWebSocketUrl } from './mobile-relay-connect-url' +import { relayConnectWebSocketUrl } from './mobile-relay-connect-url' const MobileAccessEndpointSchema = z .object({ @@ -61,7 +61,13 @@ export function toStoredMobileRelayHostOverlay( return MobileRelayHostOverlaySchema.parse({ v: 2, hostId, - endpoints: [{ id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) }], + endpoints: [ + { + id: 'relay-primary', + kind: 'relay', + url: relayConnectWebSocketUrl(relay.cellUrl, relay.relayHostId) + } + ], relayHostId: relay.relayHostId, relay }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index e37dd8ab43e..e9024e23c24 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -103,7 +103,7 @@ function dependencies(args: { readCredentialBundle: vi.fn(async () => args.bundle ?? null), writeCredentialBundle: vi.fn(async () => {}), loadHosts: vi.fn(async (): Promise => args.hosts ?? []), - saveHost: vi.fn(async () => {}), + savePairedHost: vi.fn(async () => {}), connectRelay: args.connectRelay, resolveInviteDirector: vi.fn(async () => { throw new Error('director not needed') @@ -140,7 +140,7 @@ describe('mobile relay pairing recovery', () => { }) ) expect(deps.writeCredentialBundle).toHaveBeenCalledOnce() - expect(deps.saveHost).toHaveBeenCalledOnce() + expect(deps.savePairedHost).toHaveBeenCalledOnce() expect(deps.clearJournal).toHaveBeenCalledOnce() }) @@ -159,7 +159,7 @@ describe('mobile relay pairing recovery', () => { await expect(recoverMobileRelayPairing(deps)).resolves.toBe('recovered') expect(connectRelay).not.toHaveBeenCalled() - expect(deps.saveHost).not.toHaveBeenCalled() + expect(deps.savePairedHost).not.toHaveBeenCalled() expect(deps.clearJournal).toHaveBeenCalledOnce() }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index ada16248981..c93baec9c0d 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -38,7 +38,7 @@ type RecoveryDependencies = { readCredentialBundle: typeof readMobileRelayCredentialBundle writeCredentialBundle: typeof writeMobileRelayCredentialBundle loadHosts: typeof loadHosts - saveHost: typeof savePairedHost + savePairedHost: typeof savePairedHost connectRelay: typeof connectMobileRelayForPairing resolveInviteDirector: typeof resolvePairingInviteThroughDirector now: () => number @@ -52,7 +52,7 @@ const defaultDependencies: RecoveryDependencies = { readCredentialBundle: readMobileRelayCredentialBundle, writeCredentialBundle: writeMobileRelayCredentialBundle, loadHosts, - saveHost: savePairedHost, + savePairedHost, connectRelay: connectMobileRelayForPairing, resolveInviteDirector: resolvePairingInviteThroughDirector, now: Date.now, @@ -265,7 +265,7 @@ async function publishCommitted( await dependencies.writeCredentialBundle( promotePairingJournalCredential({ journal: reconciledJournal, installed }) ) - await dependencies.saveHost(relayHost(reconciledJournal, endpoints.relay)) + await dependencies.savePairedHost(relayHost(reconciledJournal, endpoints.relay)) await dependencies.clearJournal(journal.metadata.journalId) } diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index a640be615f2..3df30542ffa 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -7,7 +7,7 @@ import { isRpcResponse } from './rpc-response-shape' import { redactSocketEndpoint } from './socket-event-debug' import type { ConnectionLogSink, RpcResponse } from './types' import { websocketPayloadToUint8 } from './websocket-payload-bytes' -import { relayWebSocketUrl } from './mobile-relay-connect-url' +import { relayConnectWebSocketUrl } from './mobile-relay-connect-url' export { RelayOuterError } from './mobile-relay-e2ee-link' import { RelayOuterError } from './mobile-relay-e2ee-link' @@ -35,7 +35,7 @@ export function connectMobileRelayForPairing(args: { onLog?: ConnectionLogSink }): PairingCandidateClient { const requestTimeoutMs = args.requestTimeoutMs ?? 30_000 - const socketUrl = relayWebSocketUrl(args.relay) + const socketUrl = relayConnectWebSocketUrl(args.relay.cellUrl, args.relay.relayHostId) const log = createPairingRelayLogger(args.onLog) const cellHost = redactSocketEndpoint(socketUrl) log('info', 'Relay: dialing cell', cellHost) diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index 29fece79f62..713c6e5cf9c 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -225,7 +225,8 @@ function dependencies( resolveRelay: vi.fn(async ({ relay }) => relay), readBundle: vi.fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)), writeBundle: vi.fn(async () => {}), - saveRelayRouting: vi.fn(async () => {}), + setRelayRouting: vi.fn(async () => {}), + directPath: 'lan', now: Date.now, randomBytes: (length: number) => new Uint8Array(length), setTimer: (handler, ms) => setTimeout(handler, ms), diff --git a/mobile/src/transport/paired-host-writer-census.test.ts b/mobile/src/transport/paired-host-writer-census.test.ts index 1e43cbe6ce5..aa7ac0c4f56 100644 --- a/mobile/src/transport/paired-host-writer-census.test.ts +++ b/mobile/src/transport/paired-host-writer-census.test.ts @@ -14,24 +14,26 @@ import { censusSourceFiles } from '../test-support/census-source-files' const MOBILE_DIR = fileURLToPath(new URL('../../', import.meta.url)) const PAIRING_WRITERS = [ - 'src/transport/host-store.ts', - 'src/transport/host-store.web.ts', 'src/transport/mobile-relay-pairing-recovery.ts', 'src/transport/pre-profile-pairing-coordinator.ts' ] -function referencingFiles(): string[] { +// A value import of `savePairedHost` from the host store, at any relative depth. +const IMPORTS_PAIRED_HOST_WRITER = + /import\s*\{[^}]*\bsavePairedHost\b[^}]*\}\s*from\s*['"](?:\.{1,2}\/)+(?:[\w-]+\/)*host-store['"]/ + +function importingFiles(): string[] { return ['src', 'app'] .flatMap((directory) => censusSourceFiles(join(MOBILE_DIR, directory))) .filter((file) => /\.tsx?$/.test(file) && !/\.test\.tsx?$/.test(file)) - .filter((file) => /\bsavePairedHost\b/.test(readFileSync(file, 'utf8'))) + .filter((file) => IMPORTS_PAIRED_HOST_WRITER.test(readFileSync(file, 'utf8'))) .map((file) => relative(MOBILE_DIR, file)) .sort() } describe('full host profile writes', () => { it('are reachable only from pairing', () => { - // Also the presence precondition: the store itself must match, or the matcher is broken. - expect(referencingFiles()).toEqual(PAIRING_WRITERS) + // Also the presence precondition: both creators must match, or the matcher is broken. + expect(importingFiles()).toEqual(PAIRING_WRITERS) }) }) diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts index aa1d1673feb..0a4484f59c3 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -108,7 +108,7 @@ function dependencies(client: RpcClient, events: string[]) { id: hostId, name: 'Blue Whale' })), - saveHost: vi.fn(async (_host: HostProfile) => { + savePairedHost: vi.fn(async (_host: HostProfile) => { events.push('save-host') }), saveJournal: vi.fn(async (_journal: MobileRelayPairingJournal) => { @@ -171,7 +171,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledWith({ + expect(deps.savePairedHost).toHaveBeenCalledWith({ id: `host-${now}`, name: 'Blue Whale', endpoint: directOffer.endpoint, @@ -201,7 +201,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: 'host-existing' }) - expect(deps.saveHost).toHaveBeenCalledWith({ + expect(deps.savePairedHost).toHaveBeenCalledWith({ id: 'host-existing', name: 'Studio Mac', endpoint: directOffer.endpoint, @@ -238,7 +238,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledOnce() + expect(deps.savePairedHost).toHaveBeenCalledOnce() expect(deps.recordDescriptorFromStatus).not.toHaveBeenCalled() }) @@ -256,7 +256,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledOnce() + expect(deps.savePairedHost).toHaveBeenCalledOnce() }) it('journals before connecting and publishes only after authoritative direct install', async () => { @@ -327,7 +327,7 @@ describe('pre-profile pairing coordinator', () => { reqId: journal!.metadata.installReqId, newResumeTokenHash: journal!.metadata.pendingResumeTokenHash }) - expect(deps.saveHost).toHaveBeenCalledWith( + expect(deps.savePairedHost).toHaveBeenCalledWith( expect.objectContaining({ id: `host-${now}`, endpoint: directOffer.endpoint, @@ -348,7 +348,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledWith( + expect(deps.savePairedHost).toHaveBeenCalledWith( expect.not.objectContaining({ relay: expect.anything() }) ) expect(events).toEqual([ @@ -380,7 +380,7 @@ describe('pre-profile pairing coordinator', () => { }) await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) - expect(deps.saveHost).toHaveBeenCalledWith( + expect(deps.savePairedHost).toHaveBeenCalledWith( expect.not.objectContaining({ relay: expect.anything() }) ) expect(events).toEqual([ @@ -542,6 +542,6 @@ describe('pre-profile pairing coordinator', () => { await expect(attempt.result).rejects.toThrow(/cancelled/) expect(client.close).toHaveBeenCalledOnce() - expect(deps.saveHost).not.toHaveBeenCalled() + expect(deps.savePairedHost).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index aa9b8b79f7b..58b1c793118 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -45,7 +45,7 @@ type Dependencies = { connectRelay: typeof connectMobileRelayForPairing resolveInviteDirector: typeof resolvePairingInviteThroughDirector resolveHostIdentity: typeof resolvePairingHostIdentity - saveHost: typeof savePairedHost + savePairedHost: typeof savePairedHost saveJournal: typeof saveMobileRelayPairingJournal updateJournal: typeof updateMobileRelayPairingJournal clearJournal: typeof clearMobileRelayPairingJournal @@ -60,7 +60,7 @@ const defaultDependencies: Dependencies = { connectRelay: connectMobileRelayForPairing, resolveInviteDirector: resolvePairingInviteThroughDirector, resolveHostIdentity: resolvePairingHostIdentity, - saveHost: savePairedHost, + savePairedHost, saveJournal: saveMobileRelayPairingJournal, updateJournal: updateMobileRelayPairingJournal, clearJournal: clearMobileRelayPairingJournal, @@ -206,7 +206,7 @@ async function runPairing( assertActive(isDisposed) if (!journal) { - await dependencies.saveHost(baseHost(offer, hostId, hostName, now)) + await dependencies.savePairedHost(baseHost(offer, hostId, hostName, now)) recordWinnerDescriptor(dependencies, hostId, winner.status) return { hostId } } @@ -231,7 +231,7 @@ async function runPairing( // Why: this commits a LAN-only host instead of failing, so the refusal code is the only // record of why the phone never got a relay endpoint. log('info', 'Relay: desktop will not serve relay pairing', provision.error.code) - await dependencies.saveHost(baseHost(offer, hostId, hostName, now)) + await dependencies.savePairedHost(baseHost(offer, hostId, hostName, now)) await dependencies.clearJournal(journal.metadata.journalId) recordWinnerDescriptor(dependencies, hostId, winner.status) return { hostId } @@ -247,7 +247,7 @@ async function runPairing( } assertActive(isDisposed) await dependencies.writeCredentialBundle(promotePairingJournalCredential({ journal, installed })) - await dependencies.saveHost(relayHost(journal, endpoints.relay)) + await dependencies.savePairedHost(relayHost(journal, endpoints.relay)) await dependencies.clearJournal(journal.metadata.journalId) recordWinnerDescriptor(dependencies, hostId, winner.status) return { hostId }