fix(mobile): recover missing desktop relay provider on pairing

This commit is contained in:
Jinwoo-H
2026-09-11 00:10:02 -04:00
parent fb9ba4b681
commit 0f8c403d46
13 changed files with 311 additions and 42 deletions
+73
View File
@@ -10,6 +10,79 @@
}
},
"gates": [
{
"id": "desktop-relay.initialization-recovery",
"title": "Missing desktop Relay provider can recover without restart",
"maturity": "experimental",
"protection": "partial",
"owner": "desktop-runtime",
"layer": "main-unit",
"surfaces": ["desktop relay initialization", "mobile pairing"],
"platforms": ["macos", "linux", "windows"],
"providers": ["relay"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["relay"],
"coverageNotes": "Injected startup and IPC contracts plus renderer interaction; no real cloud assignment.",
"motivatingLinks": ["https://github.com/stablyai/orca/issues/20005"],
"invariant": "A failed initialization remains retryable; successful recovery installs one provider and LAN and quit never trigger recovery.",
"oracle": "Fail construction and start, retry initialization, count installed providers and cleanup; verify automatic pairing and Retry invoke recovery before minting.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/startup/main-process-relay-startup.test.ts src/main/ipc/mobile-relay-recovery.test.ts src/main/ipc/mobile.test.ts src/renderer/src/components/mobile/MobileHero.test.tsx"
],
"testFiles": [
"src/main/startup/main-process-relay-startup.test.ts",
"src/main/ipc/mobile-relay-recovery.test.ts",
"src/renderer/src/components/mobile/MobileHero.test.tsx"
],
"assertionRefs": [
{
"file": "src/main/startup/main-process-relay-startup.test.ts",
"assertions": [
"failed construction recovers once",
"partial startup is cleaned up",
"quit prevents recovery"
]
},
{
"file": "src/main/ipc/mobile-relay-recovery.test.ts",
"assertions": ["automatic pairing recovers before minting", "LAN skips recovery"]
},
{
"file": "src/renderer/src/components/mobile/MobileHero.test.tsx",
"assertions": ["missing-provider notice offers a working Retry button"]
}
],
"evidenceRuns": [
{
"date": "2026-09-11",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/startup/main-process-relay-startup.test.ts src/main/ipc/mobile-relay-recovery.test.ts src/main/ipc/mobile.test.ts src/renderer/src/components/mobile/MobileHero.test.tsx",
"result": "passed",
"durationSeconds": 6.01,
"summary": "60 tests passed across four files."
}
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "focused unit tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Local focused runs only."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "UI test fails before Retry is exposed; IPC recovery test fails with callback disabled. Startup recovery tests pass with injected constructor/start failures."
},
"performanceBudget": {
"required": false,
"evidence": "Synchronous idempotent initialization; no new timers or polling."
},
"promotionCriteria": ["Exercise startup recovery against real Relay in CI."],
"knownGaps": ["Live cloud assignment and physical mobile pairing not exercised."],
"demotionRule": "Demote on duplicate provider installation or post-quit recovery."
},
{
"id": "mobile-push.headless-startup-and-policy",
"title": "Headless push lifecycle and mobile delivery policy",
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest'
const { handlers } = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>()
}))
vi.mock('electron', () => ({
app: { isPackaged: false },
ipcMain: {
handle: (channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
}
},
shell: { openExternal: vi.fn() }
}))
import { registerMobileHandlers } from './mobile'
describe('mobile Relay recovery', () => {
it('recovers Relay before automatic pairing, including Retry, but never for LAN', async () => {
const events: string[] = []
const onBeforeRelayPairing = vi.fn(() => {
events.push('recover')
})
const createMobilePairingOffer = vi.fn(async () => {
events.push('pair')
return { available: false, reason: 'relay_mint_failed' }
})
registerMobileHandlers({ createMobilePairingOffer } as never, { onBeforeRelayPairing })
const request = handlers.get('mobile:getPairingQR')!
await request({}, { address: '127.0.0.1' })
await request({}, { address: '127.0.0.1', connectionMode: 'automatic', rotate: true })
await request({}, { address: '127.0.0.1', connectionMode: 'local-only' })
expect(events).toEqual(['recover', 'pair', 'recover', 'pair', 'pair'])
expect(onBeforeRelayPairing).toHaveBeenCalledTimes(2)
})
})
+4
View File
@@ -51,6 +51,7 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
export type MobileHandlerDependencies = {
firewallEnvironment?: WindowsMobileFirewallEnvironment
openWindowsNetworkSettings?: () => Promise<void>
onBeforeRelayPairing?: () => void
getRelayStatus?: () => MobileRelayStatusDetail
consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean
encodePairingQr?: (pairingUrl: string) => Promise<MobilePairingQrResult>
@@ -110,6 +111,9 @@ export function registerMobileHandlers(
// `rotate: true` (explicit "Regenerate" intent because the prior token
// may have been exposed), we discard any pending token and mint a fresh
// one so the new QR carries a different credential.
if (args?.connectionMode !== 'local-only') {
dependencies.onBeforeRelayPairing?.()
}
const offer = await rpcServer.createMobilePairingOffer({
address: ip,
connectionMode: args?.connectionMode,
@@ -73,6 +73,27 @@ function service(): DesktopRelayService {
}
describe('DesktopRelayService broker liveness', () => {
it('pairs after signing in without replacing the service created while signed out', async () => {
const relayService = service()
const signedIn = await fakes.readRelayAuthContext()
fakes.readRelayAuthContext.mockResolvedValue(null)
try {
relayService.start()
await expect(relayService.createPairingRelay('device-1')).rejects.toThrow(
'relay_control_not_active'
)
expect(fakes.brokers).toHaveLength(0)
fakes.readRelayAuthContext.mockResolvedValue(signedIn)
relayService.authMutated()
await expect(relayService.createPairingRelay('device-1')).resolves.toMatchObject({
binding: { relayDeviceId: 'device-1' }
})
expect(fakes.brokers).toHaveLength(1)
} finally {
relayService.stop()
}
})
it('pairs through a replacement when the owned broker control died', async () => {
// Why: ownership stays 'valid' after a control socket dies, so the stale
// handle otherwise reaches create_pairing_relay and fails the pairing.
@@ -113,7 +113,7 @@ describe('initial proxy application ordering', () => {
const windowIndex = desktop.indexOf('openMainWindow()')
const proxyIndex = desktop.indexOf('await state.initialProxyApplicationReady')
const relayIndex = desktop.indexOf('new DesktopRelayService(')
const relayIndex = desktop.indexOf('startDesktopRelayService(')
expect(windowIndex).toBeGreaterThanOrEqual(0)
expect(proxyIndex).toBeGreaterThan(windowIndex)
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const fakes = vi.hoisted(() => ({
state: { isQuitting: false, desktopRelayService: null as unknown },
configured: true,
construct: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
authMutated: vi.fn(),
createPairingRelay: vi.fn()
}))
vi.mock('electron', () => ({ app: { getVersion: () => 'test' } }))
vi.mock('./main-process-state', () => ({ mainProcessState: fakes.state }))
vi.mock('./main-process-relay-status', () => ({ publishDesktopRelayStatus: vi.fn() }))
vi.mock('../orca-profiles/profile-storage-paths', () => ({
getProfileUserDataPath: () => '/unused'
}))
vi.mock('../orca-profiles/profile-cloud-auth-config', () => ({
getOrcaCloudAuthConfig: () => ({ configured: fakes.configured, config: {} })
}))
vi.mock('../runtime/relay/desktop-relay-service', () => ({
DesktopRelayService: class {
constructor() {
fakes.construct()
}
start = fakes.start
stop = fakes.stop
authMutated = fakes.authMutated
createPairingRelay = fakes.createPairingRelay
}
}))
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
describe('desktop relay initialization recovery', () => {
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
fakes.state.isQuitting = false
fakes.state.desktopRelayService = null
fakes.configured = true
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
function runtime() {
return { setMobileRelayPairingProvider: vi.fn() } as unknown as OrcaRuntimeRpcServer
}
it('waits for desktop startup, recovers a failed construction, and installs one provider', async () => {
const { ensureDesktopRelayService, startDesktopRelayService } =
await import('./main-process-relay-startup')
const rpc = runtime()
expect(ensureDesktopRelayService()).toBeNull()
expect(fakes.construct).not.toHaveBeenCalled()
fakes.construct.mockImplementationOnce(() => {
throw new Error('mobile_runtime_not_ready')
})
startDesktopRelayService(rpc)
expect(fakes.state.desktopRelayService).toBeNull()
expect(rpc.setMobileRelayPairingProvider).toHaveBeenLastCalledWith(null)
ensureDesktopRelayService()?.authMutated()
const service = fakes.state.desktopRelayService
expect(service).not.toBeNull()
expect(fakes.authMutated).toHaveBeenCalledOnce()
expect(ensureDesktopRelayService()).toBe(service)
expect(fakes.construct).toHaveBeenCalledTimes(2)
expect(fakes.start).toHaveBeenCalledOnce()
const provider = vi.mocked(rpc.setMobileRelayPairingProvider).mock.calls.at(-1)![0]!
fakes.createPairingRelay.mockResolvedValue({ relay: 'test-offer' })
await expect(provider.createPairingRelay('phone')).resolves.toEqual({ relay: 'test-offer' })
expect(fakes.createPairingRelay).toHaveBeenCalledWith('phone')
})
it('removes a partially started provider before allowing another attempt', async () => {
const { ensureDesktopRelayService, startDesktopRelayService } =
await import('./main-process-relay-startup')
const rpc = runtime()
fakes.start.mockImplementationOnce(() => {
throw new Error('start failed')
})
startDesktopRelayService(rpc)
expect(fakes.stop).toHaveBeenCalledOnce()
expect(rpc.setMobileRelayPairingProvider).toHaveBeenLastCalledWith(null)
expect(fakes.state.desktopRelayService).toBeNull()
expect(ensureDesktopRelayService()).not.toBeNull()
})
it('skips unconfigured builds and never resurrects a quitting desktop', async () => {
const { ensureDesktopRelayService, startDesktopRelayService } =
await import('./main-process-relay-startup')
fakes.configured = false
startDesktopRelayService(runtime())
ensureDesktopRelayService()
expect(fakes.construct).not.toHaveBeenCalled()
fakes.configured = true
fakes.state.isQuitting = true
expect(ensureDesktopRelayService()).toBeNull()
expect(fakes.construct).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,55 @@
import { app } from 'electron'
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { DesktopRelayService } from '../runtime/relay/desktop-relay-service'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import { publishDesktopRelayStatus } from './main-process-relay-status'
import { mainProcessState as state } from './main-process-state'
let relayRuntime: OrcaRuntimeRpcServer | null = null
export function startDesktopRelayService(runtimeRpc: OrcaRuntimeRpcServer): void {
// Auth and pairing can arrive before startup has applied the proxy and started RPC.
relayRuntime = runtimeRpc
ensureDesktopRelayService()
}
export function ensureDesktopRelayService(): DesktopRelayService | null {
if (state.isQuitting || !relayRuntime) {
return null
}
if (state.desktopRelayService) {
return state.desktopRelayService
}
const cloudAuth = getOrcaCloudAuthConfig()
if (!cloudAuth.configured) {
return null
}
let service: DesktopRelayService | null = null
try {
service = new DesktopRelayService({
authConfig: cloudAuth.config,
userDataPath: getProfileUserDataPath(),
appVersion: app.getVersion(),
runtimeRpc: relayRuntime,
onStatus: publishDesktopRelayStatus
})
const relayService = service
relayRuntime.setMobileRelayPairingProvider({
createPairingRelay: (id) => relayService.createPairingRelay(id),
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
onDemandStateChanged: () => relayService.demandStateChanged(),
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
})
relayService.start()
state.desktopRelayService = relayService
return relayService
} catch {
relayRuntime.setMobileRelayPairingProvider(null)
service?.stop()
console.warn('[relay] Desktop relay initialization failed; retry pairing to recover')
return null
}
}
@@ -1,7 +1,5 @@
import { app, powerMonitor, type BrowserWindow } from 'electron'
import { is } from '@electron-toolkit/utils'
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import {
getCanonicalUserDataPath,
migrateMobilePairingDataToCanonicalUserDataPath
@@ -13,8 +11,8 @@ import { LocalPtyProvider } from '../providers/local-pty-provider'
import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types'
import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend'
import { browserManager } from '../browser/browser-manager'
import { getDesktopRelayStatus, publishDesktopRelayStatus } from './main-process-relay-status'
import { DesktopRelayService } from '../runtime/relay/desktop-relay-service'
import { getDesktopRelayStatus } from './main-process-relay-status'
import { ensureDesktopRelayService, startDesktopRelayService } from './main-process-relay-startup'
import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve'
import {
bindTerminalRuntimeStartupServices,
@@ -94,6 +92,9 @@ function installRuntimeRpc(
state.runtimeRpc = runtimeRpc
registerMobileHandlers(runtimeRpc, {
getRelayStatus: getDesktopRelayStatus,
onBeforeRelayPairing: () => {
ensureDesktopRelayService()
},
consumePendingUnpairedDeviceAuthFailure: (webContentsId) => {
if (
!state.mainWindow ||
@@ -249,35 +250,8 @@ async function launchDesktopMode(
// Why after the proxy await: the push gateway client is an app-owned fetcher, so it must not
// issue its first request ahead of the persisted proxy.
startDesktopPushService(runtimeRpc)
const cloudAuth = getOrcaCloudAuthConfig()
if (cloudAuth.configured) {
try {
const relayService = new DesktopRelayService({
authConfig: cloudAuth.config,
userDataPath: getProfileUserDataPath(),
appVersion: app.getVersion(),
runtimeRpc,
onStatus: publishDesktopRelayStatus
})
state.desktopRelayService = relayService
runtimeRpc.setMobileRelayPairingProvider({
createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId),
onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item),
onDemandStateChanged: () => relayService.demandStateChanged(),
getEndpoints: (context, params) => relayService.getEndpoints(context, params),
provisionRelay: (context, params) => relayService.provisionRelay(context, params)
})
relayService.start()
// Why: sleeping past relay-token expiry kills the broker with no retry
// timer; resume is the moment that state becomes recoverable.
powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive())
} catch (error) {
console.warn(
'[relay] Desktop relay startup unavailable:',
error instanceof Error ? error.message : String(error)
)
}
}
startDesktopRelayService(runtimeRpc)
powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive())
// Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window.
win.once('show', () => {
// Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener.
@@ -15,6 +15,7 @@ import {
import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation'
import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch'
import { isRecoveryReloadInFlight } from './main-window-lifecycle-flags'
import { ensureDesktopRelayService } from './main-process-relay-startup'
import { RELAY_HOST_CLOSE_REASON } from '../../shared/relay-host-close-reason'
export function attachMainWindowCoreServices(
@@ -90,7 +91,7 @@ export function attachMainWindowCoreServices(
store
})
},
onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(),
onOrcaProfileAuthMutation: () => ensureDesktopRelayService()?.authMutated(),
// Sign-out is the one fence a paired phone can be told about; quit and
// relaunch above stay reasonless so a restart never reads as signed out.
onBeforeOrcaProfileSignOut: () =>
@@ -214,18 +214,21 @@ describe('HeroFlow height', () => {
await waitFor(() => expect(screen.getByText(/Creating a new pairing code/)).toBeVisible())
})
it('does not offer a futile retry when Relay is unavailable on the desktop', () => {
it('lets the user retry desktop Relay initialization', async () => {
const onRetryRelay = vi.fn()
renderFlow(1, {
relayMintFailure: {
code: 'relay_provider_unavailable',
stage: 'provider_missing',
message: 'Orca Relay is not available on this desktop'
}
},
onRetryRelay
})
expect(screen.getByRole('alert')).toHaveTextContent(
'Orca Relay isn’t available on this desktop'
)
expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Retry Relay' }))
expect(onRetryRelay).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
})
@@ -65,7 +65,7 @@ export function MobileRelayMintFailureNotice({
: providerMissing
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody',
'Use LAN to pair over Tailscale or the same Wi‑Fi.'
'Retry Relay. If it still fails, restart Orca or use LAN over Tailscale or the same Wi‑Fi.'
)
: reconnectRequired
? translate(
@@ -105,7 +105,7 @@ export function MobileRelayMintFailureNotice({
<Button type="button" size={compact ? 'xs' : 'sm'} onClick={onUseLan}>
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')}
</Button>
{!providerMissing && !reconnectRequired ? (
{!reconnectRequired ? (
<Button
type="button"
size={compact ? 'xs' : 'sm'}
+1 -1
View File
@@ -13868,7 +13868,7 @@
"unavailableTitle": "Orca Relay isn’t available on this desktop.",
"title": "Couldn’t create a Relay pairing code.",
"retryingBody": "Creating a new pairing code. This can take a moment over a remote connection.",
"unavailableBody": "Use LAN to pair over Tailscale or the same Wi‑Fi.",
"unavailableBody": "Retry Relay. If it still fails, restart Orca or use LAN over Tailscale or the same Wi‑Fi.",
"body": "Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.",
"useLan": "Use LAN",
"retrying": "Retrying…",
+1 -1
View File
@@ -13345,7 +13345,7 @@
"unavailableTitle": "Orca Relay n'est pas disponible sur ce poste de bureau.",
"title": "Impossible de créer un code d'appairage Relay.",
"retryingBody": "Création d'un nouveau code d'appairage. Cela peut prendre un moment via une connexion distante.",
"unavailableBody": "Utilisez le LAN pour appairer via Tailscale ou le même Wi-Fi.",
"unavailableBody": "Réessayez Orca Relay. Si le problème persiste, redémarrez Orca ou utilisez le LAN via Tailscale ou le même Wi-Fi.",
"body": "Réessayez, ou utilisez le LAN pour appairer via Tailscale ou le même Wi-Fi.",
"useLan": "Utiliser le LAN",
"retrying": "Nouvelle tentative…",