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 <neil@stably.ai>
This commit is contained in:
Jinwoo-H
2026-09-25 19:12:10 -04:00
co-authored by mmarabel Neil
parent 6ba444bc91
commit c5c1477ad4
31 changed files with 203 additions and 252 deletions
@@ -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)
},
@@ -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)}`
@@ -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([])
+2 -2
View File
@@ -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])
+2 -2
View File
@@ -149,7 +149,7 @@ function removeOrphanOverlayIfUnpaired(hostId: string): Promise<void> {
// 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)
})
@@ -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<RpcResponse> => ({
@@ -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(() => {
@@ -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<MobileConnectionPath, 'relay'> {
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<MobileConnectionPath, 'relay'> } | null> {
): Promise<RpcClient | null> {
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
}
@@ -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<MobileConnectionPath, 'relay'>
},
private readonly hooks: {
hysteresis: MobileEndpointHysteresis
host: () => HostProfile
canSchedule: () => boolean
canAttempt: () => boolean
beginOperation: () => void
@@ -75,7 +74,6 @@ export class DirectReturnProbe {
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = 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()
@@ -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<void> {
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<void>((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
@@ -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,
@@ -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<MobileConnectionPath, 'relay'>
openRelay: (
relay: MobileRelayEndpoint,
credential: { token: string; version: number },
@@ -18,7 +20,7 @@ export type MobileEndpointSupervisorDependencies = {
resolveRelay: typeof resolveMobileRelayEndpoint
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
saveRelayRouting: (hostId: string, relay: MobileRelayEndpoint) => Promise<void>
setRelayRouting: (hostId: string, relay: MobileRelayEndpoint) => Promise<void>
now: () => number
randomBytes: (length: number) => Uint8Array
setTimer: ScheduleTimer
@@ -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<void>((resolve) => {
finishCredentialWrite = resolve
})
const writeBundle = vi
.fn<(value: MobileRelayCredentialBundle) => Promise<void>>()
.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()
})
})
@@ -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<MobileEndpointSupervisorDependencies, 'saveRelayRouting'>,
stopped: boolean
): Promise<HostProfile> {
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<HostProfile, 'id' | 'relay'>,
private readonly dependencies: Pick<MobileEndpointSupervisorDependencies, 'setRelayRouting'>,
private readonly isStopped: () => boolean
) {
this.relay = host.relay
this.hostId = host.id
}
current(): MobileRelayEndpoint | undefined {
return this.relay
}
async adopt(relay: MobileRelayEndpoint): Promise<void> {
// 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 {
@@ -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,
@@ -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)
@@ -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<void> {
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<void> {
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.
@@ -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)
}
@@ -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,
@@ -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)
})
@@ -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(
@@ -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,
@@ -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])
@@ -7,7 +7,7 @@ import {
} from './mobile-relay-host-overlay'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
let overlayMutation: Promise<void> = Promise.resolve()
let overlayMutation: Promise<unknown> = Promise.resolve()
function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null {
if (raw === null) {
@@ -39,15 +39,16 @@ async function readOverlaysForMutation(): Promise<MobileRelayHostOverlay[]> {
async function mutateOverlays(
update: (overlays: MobileRelayHostOverlay[]) => MobileRelayHostOverlay[]
): Promise<void> {
): Promise<boolean> {
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<boolean> {
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<void> {
return removeMobileRelayHostOverlays([hostId])
}
export function removeMobileRelayHostOverlays(hostIds: readonly string[]): Promise<void> {
export async function removeMobileRelayHostOverlays(hostIds: readonly string[]): Promise<void> {
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
})
}
@@ -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
})
@@ -103,7 +103,7 @@ function dependencies(args: {
readCredentialBundle: vi.fn(async () => args.bundle ?? null),
writeCredentialBundle: vi.fn(async () => {}),
loadHosts: vi.fn(async (): Promise<HostProfile[]> => 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()
})
@@ -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)
}
@@ -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)
@@ -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),
@@ -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)
})
})
@@ -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()
})
})
@@ -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 }