Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/main/runtime/relay/desktop-relay-service-broker-liveness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaCloudAuthConfig } from '../../orca-profiles/profile-cloud-auth-config'
import type { OrcaRuntimeRpcServer } from '../runtime-rpc'

const fakes = vi.hoisted(() => ({
readRelayAuthContext: vi.fn(),
brokers: [] as { live: boolean }[]
}))

vi.mock('./relay-auth-context', () => ({ readRelayAuthContext: fakes.readRelayAuthContext }))

vi.mock('./relay-session-broker', () => {
// Mirrors the real broker: a control that died rejects with relay_control_not_active.
class RelaySessionBroker {
live = true
readonly hostId = 'relay-host-1'
readonly ownerIdentityKey = 'user-1\0profile-1\0org-1'
readonly endpoint = { v: 1 as const, relayHostId: 'relay-host-1' }
isLive(): boolean {
return this.live
}
closeNow(): void {
this.live = false
}
async createPairingRelay(relayDeviceId: string): Promise<unknown> {
if (!this.live) {
throw new Error('relay_control_not_active')
}
return { v: 1, relayHostId: this.hostId, relayDeviceId, inviteExpiresAt: 0 }
}
static connect = vi.fn(async () => {
const broker = new RelaySessionBroker()
fakes.brokers.push(broker)
return broker
})
}
return { RelaySessionBroker }
})

import { DesktopRelayService } from './desktop-relay-service'

function service(): DesktopRelayService {
fakes.brokers.length = 0
fakes.readRelayAuthContext.mockResolvedValue({
identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' },
accessToken: 'access-1',
relayEntitled: true
})
const runtimeRpc = {
getE2EEKeypair: () => ({
publicKey: new Uint8Array(32).fill(7),
secretKey: new Uint8Array(32).fill(9),
publicKeyB64: 'x'
}),
getMobileSocketWiring: () => ({ attachTransport: () => () => {} }),
getRelayRevokeOutbox: () => ({ pendingFor: () => [], remove: vi.fn() }),
getDeviceRegistry: () => ({
listDevices: () => [],
getDevice: () => ({ deviceId: 'device-1', scope: 'mobile' }),
getMobilePairingConnectionMode: () => 'automatic'
})
} as unknown as OrcaRuntimeRpcServer
return new DesktopRelayService({
authConfig: {
relayDirectorUrl: 'https://relay.example.test',
relayTokenEndpoint: 'https://login.example.test/relay-token'
} as OrcaCloudAuthConfig,
userDataPath: '/tmp/orca-relay-liveness-test',
appVersion: '1.4.188',
runtimeRpc,
onStatus: () => {}
})
}

describe('DesktopRelayService broker liveness', () => {
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.
const relayService = service()
try {
await expect(relayService.createPairingRelay('device-1')).resolves.toMatchObject({
binding: { relayDeviceId: 'device-1' }
})
expect(fakes.brokers).toHaveLength(1)

fakes.brokers[0]!.live = false

await expect(relayService.createPairingRelay('device-2')).resolves.toMatchObject({
binding: { relayDeviceId: 'device-2' }
})
expect(fakes.brokers).toHaveLength(2)
expect(fakes.brokers[1]!.live).toBe(true)
} finally {
relayService.stop()
}
})

it('keeps using a live broker instead of replacing it', async () => {
const relayService = service()
try {
await relayService.createPairingRelay('device-1')
await relayService.createPairingRelay('device-2')
expect(fakes.brokers).toHaveLength(1)
} finally {
relayService.stop()
}
})
})
3 changes: 1 addition & 2 deletions src/main/runtime/relay/desktop-relay-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,7 @@ export class DesktopRelayService {
}

private async activeBrokerForDemand(): Promise<RelaySessionBroker | null> {
const broker =
this.coordinator.getActiveBroker() ?? (await this.coordinator.waitForActiveBroker())
const broker = this.coordinator.getLiveBroker() ?? (await this.coordinator.waitForLiveBroker())
return broker instanceof RelaySessionBroker ? broker : null
}

Expand Down
34 changes: 34 additions & 0 deletions src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,40 @@ describe('RelayAuthCoordinator transient recovery', () => {
})

describe('RelayAuthCoordinator liveness safety net', () => {
it('withholds a dead broker from control work while identity matching still sees it', async () => {
vi.useFakeTimers()
const dead = { closeNow: vi.fn(), isLive: () => false }
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
openBroker: vi.fn().mockResolvedValue(dead),
onStatus: vi.fn(),
random: () => 0.5
})

coordinator.reconcile()
await vi.advanceTimersByTimeAsync(0)
expect(coordinator.getActiveBroker()).toBe(dead)
expect(coordinator.getLiveBroker()).toBeNull()
})

it('treats a broker that cannot report liveness as usable, not dead', async () => {
// Why: unverifiable is not exited — only a broker that proves its control
// died is withheld; silence must never cost a working relay.
vi.useFakeTimers()
const unverifiable = { closeNow: vi.fn() }
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
openBroker: vi.fn().mockResolvedValue(unverifiable),
onStatus: vi.fn(),
random: () => 0.5
})

coordinator.reconcile()
await vi.advanceTimersByTimeAsync(0)
expect(coordinator.getLiveBroker()).toBe(unverifiable)
await expect(coordinator.waitForLiveBroker()).resolves.toBe(unverifiable)
})

it('reopens a broker that died leaving no retry timer behind', async () => {
// Why: an auth refresh failing past token expiry closes the broker with
// no timer; only ensureLive (periodic/power-resume) can revive it.
Expand Down
12 changes: 6 additions & 6 deletions src/main/runtime/relay/relay-auth-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('RelayAuthCoordinator', () => {
onStatus: (status) => statuses.push(status)
})
coordinator.reconcile()
await coordinator.waitForActiveBroker()
await coordinator.waitForLiveBroker()
expect(openBroker).not.toHaveBeenCalled()
expect(statuses.at(-1)).toBe('standby')
})
Expand All @@ -43,10 +43,10 @@ describe('RelayAuthCoordinator', () => {
lingerMs: 250
})
coordinator.reconcile()
await coordinator.waitForActiveBroker()
await coordinator.waitForLiveBroker()
demanded = true
coordinator.reconcile()
await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker)
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
demanded = false
coordinator.reconcile()
await vi.waitFor(() => expect(statuses.at(-1)).toBe('standby'))
Expand All @@ -65,7 +65,7 @@ describe('RelayAuthCoordinator', () => {
lingerMs: 20
})
coordinator.reconcile()
await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker)
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
demanded = false
coordinator.reconcile()
demanded = true
Expand All @@ -85,10 +85,10 @@ describe('RelayAuthCoordinator', () => {
lingerMs: 10_000
})
coordinator.reconcile()
await expect(coordinator.waitForActiveBroker()).resolves.toBe(broker)
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
current = { ...context, identity: { ...context.identity, profileId: 'profile-2' } }
coordinator.reconcile()
await coordinator.waitForActiveBroker()
await coordinator.waitForLiveBroker()
expect(broker.closeNow).toHaveBeenCalledOnce()
})

Expand Down
14 changes: 11 additions & 3 deletions src/main/runtime/relay/relay-auth-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,18 @@ export class RelayAuthCoordinator {
this.options.onStatus('offline')
}

// Raw ownership handle for identity matching (revoke routing); control work uses getLiveBroker.
getActiveBroker(): CoordinatedRelayBroker | null {
return this.ownership?.valid ? this.ownership.broker : null
}

// Why: ownership stays valid across a control death, so control work must
// apply the same liveness gate reconcile does; unprovable liveness stays usable.
getLiveBroker(): CoordinatedRelayBroker | null {
const broker = this.getActiveBroker()
return broker && (broker.isLive?.() ?? true) ? broker : null
}

// Why: some broker deaths end with no retry timer — an auth refresh that
// fails past token expiry (laptop sleep), or a transient context read that
// returned null at open. Periodic/power-resume callers use this as a
Expand All @@ -108,16 +116,16 @@ export class RelayAuthCoordinator {
this.beginReconcile(false)
}

async waitForActiveBroker(): Promise<CoordinatedRelayBroker | null> {
async waitForLiveBroker(): Promise<CoordinatedRelayBroker | null> {
while (!this.stopped) {
const broker = this.getActiveBroker()
const broker = this.getLiveBroker()
if (broker) {
return broker
}
const pending = this.latestReconcile
await pending
if (pending === this.latestReconcile) {
return this.getActiveBroker()
return this.getLiveBroker()
}
}
return null
Expand Down
Loading