From 0f8c403d4672d6ced83c858cce8e92e2b04eb3a8 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 11 Sep 2026 00:10:02 -0400 Subject: [PATCH] fix(mobile): recover missing desktop relay provider on pairing --- config/reliability-gates.jsonc | 73 +++++++++++++ src/main/ipc/mobile-relay-recovery.test.ts | 37 +++++++ src/main/ipc/mobile.ts | 4 + ...ktop-relay-service-broker-liveness.test.ts | 21 ++++ .../main-process-ready-phase-ordering.test.ts | 2 +- .../main-process-relay-startup.test.ts | 101 ++++++++++++++++++ .../startup/main-process-relay-startup.ts | 55 ++++++++++ .../startup/main-process-runtime-launch.ts | 40 ++----- src/main/startup/main-window-core-services.ts | 3 +- .../src/components/mobile/MobileHero.test.tsx | 9 +- .../mobile-relay-mint-failure-notice.tsx | 4 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- 13 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 src/main/ipc/mobile-relay-recovery.test.ts create mode 100644 src/main/startup/main-process-relay-startup.test.ts create mode 100644 src/main/startup/main-process-relay-startup.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0ba70ddcf1e..e1bad67663f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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", diff --git a/src/main/ipc/mobile-relay-recovery.test.ts b/src/main/ipc/mobile-relay-recovery.test.ts new file mode 100644 index 00000000000..8888f6e2dcb --- /dev/null +++ b/src/main/ipc/mobile-relay-recovery.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' + +const { handlers } = vi.hoisted(() => ({ + handlers: new Map 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) + }) +}) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index ccd3a6742ef..405e2380e63 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -51,6 +51,7 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant { export type MobileHandlerDependencies = { firewallEnvironment?: WindowsMobileFirewallEnvironment openWindowsNetworkSettings?: () => Promise + onBeforeRelayPairing?: () => void getRelayStatus?: () => MobileRelayStatusDetail consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean encodePairingQr?: (pairingUrl: string) => Promise @@ -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, diff --git a/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts b/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts index 7eac4f1f324..6f7379fbbeb 100644 --- a/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts +++ b/src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts @@ -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. diff --git a/src/main/startup/main-process-ready-phase-ordering.test.ts b/src/main/startup/main-process-ready-phase-ordering.test.ts index 749eb56cb0f..243bf074713 100644 --- a/src/main/startup/main-process-ready-phase-ordering.test.ts +++ b/src/main/startup/main-process-ready-phase-ordering.test.ts @@ -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) diff --git a/src/main/startup/main-process-relay-startup.test.ts b/src/main/startup/main-process-relay-startup.test.ts new file mode 100644 index 00000000000..e68d0090aba --- /dev/null +++ b/src/main/startup/main-process-relay-startup.test.ts @@ -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() + }) +}) diff --git a/src/main/startup/main-process-relay-startup.ts b/src/main/startup/main-process-relay-startup.ts new file mode 100644 index 00000000000..5def298308b --- /dev/null +++ b/src/main/startup/main-process-relay-startup.ts @@ -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 + } +} diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index fdedf310cbb..8d750e1f95e 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -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. diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index d3ece383ee3..8b904b43ee5 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -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: () => diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index 1ddcb72b1e6..7edbac09a8b 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -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() }) diff --git a/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx b/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx index ee3d82a51d1..2233ac517fc 100644 --- a/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx +++ b/src/renderer/src/components/mobile/mobile-relay-mint-failure-notice.tsx @@ -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({ - {!providerMissing && !reconnectRequired ? ( + {!reconnectRequired ? (