Skip to content
Open
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
76 changes: 36 additions & 40 deletions src/main/runtime/relay/desktop-relay-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,8 @@ import { RelayAuthCoordinator } from './relay-auth-coordinator'
import { relayOfflineReasonMintFailureCode } from './relay-offline-reason'
import { RelaySessionBroker, type RelayBrokerStatus } from './relay-session-broker'
import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer'
import type {
RelayRevokeOutbox,
RelayDeviceBinding,
RelayRevokeOutboxItem
} from './relay-revoke-outbox'
import type { RelayDeviceBinding, RelayRevokeOutboxItem } from './relay-revoke-outbox'
import { RelayRevokeOutboxFlusher } from './relay-revoke-outbox-flush'
import { deriveRelayHostId } from './relay-http-client'
import { RelayDemandLedger } from './relay-demand-ledger'
import { createRelayRegionPreferenceReader } from './relay-region-preference'
Expand Down Expand Up @@ -45,13 +42,15 @@ const RELAY_LIVENESS_INTERVAL_MS = 5 * 60_000

export class DesktopRelayService {
private readonly coordinator: RelayAuthCoordinator
private readonly revokeOutbox: RelayRevokeOutbox
private readonly revokeFlusher: RelayRevokeOutboxFlusher
private readonly runtimeRpc: OrcaRuntimeRpcServer
private readonly demandLedger: RelayDemandLedger
private readonly hostMobilePairingConnectionMode?: () => MobilePairingConnectionMode
private demandExpiryTimer: ReturnType<typeof setTimeout> | null = null
private livenessTimer: ReturnType<typeof setInterval> | null = null
private stopped = false
// Cleared only by an explicit re-arm (start/authMutated); see fenceAndCloseNow.
private fenced = false

constructor(options: DesktopRelayServiceOptions) {
const keypair = options.runtimeRpc.getE2EEKeypair()
Expand All @@ -60,11 +59,16 @@ export class DesktopRelayService {
throw new Error('mobile_runtime_not_ready')
}
this.runtimeRpc = options.runtimeRpc
this.revokeOutbox = options.runtimeRpc.getRelayRevokeOutbox()
const revokeOutbox = options.runtimeRpc.getRelayRevokeOutbox()
this.revokeFlusher = new RelayRevokeOutboxFlusher({
outbox: revokeOutbox,
isHalted: () => this.stopped || this.fenced,
onDrained: () => this.refreshDemand()
})
this.hostMobilePairingConnectionMode = options.hostMobilePairingConnectionMode
this.demandLedger = new RelayDemandLedger({
deviceRegistry: options.runtimeRpc.getDeviceRegistry()!,
revokeOutbox: this.revokeOutbox,
revokeOutbox,
relayHostId: deriveRelayHostId(keypair.publicKey),
isRelayAllowedForDevice: (deviceId) => this.isRelayAllowedForDevice(deviceId)
})
Expand All @@ -89,36 +93,41 @@ export class DesktopRelayService {
onAssignedCellActive: regionPreference.noteAssignedCell,
onStatus: options.onStatus
})
void this.flushRevokeOutbox(broker)
void this.revokeFlusher.flushAll(broker)
return broker
},
onStatus: options.onStatus
})
}

start(): void {
this.fenced = false
this.refreshDemand()
}

// Safe to call from any wake signal (power resume, network change).
ensureLive(): void {
if (!this.stopped) {
if (!this.stopped && !this.fenced) {
this.coordinator.ensureLive()
}
}

authMutated(): void {
this.fenced = false
this.refreshDemand()
}

// The re-armable fence, by design: sign-out and relaunch call this
// (main-window-core-services.ts onBeforeOrcaProfileSignOut / onBeforeRelaunch)
// and want the next authMutated to bring Relay back. Quit deliberately does
// NOT use this — it calls stop(), which is terminal. Keep the two apart.
fenceAndCloseNow(hostCloseReason?: RelayHostCloseReason): void {
// Why: a fence must be hard — a surviving liveness tick could catch the
// window between the pre-sign-out fence and the profile wipe and briefly
// resurrect a broker. The next auth mutation re-arms via refreshDemand.
if (this.livenessTimer) {
clearInterval(this.livenessTimer)
this.livenessTimer = null
}
// Why a latch rather than just clearing the timers: clearing only covered
// the liveness tick, and everything else outliving the fence lands in
// refreshDemand and re-arms it — a settling mint's finally, a pending
// invite-expiry wake, a power-resume ensureLive.
this.fenced = true
this.clearTimers()
this.coordinator.fenceAndCloseNow(hostCloseReason)
}

Expand Down Expand Up @@ -148,7 +157,7 @@ export class DesktopRelayService {
broker.hostId === item.relayHostId &&
broker.ownerIdentityKey === item.ownerIdentityKey
) {
void this.flushRevoke(broker, item)
void this.revokeFlusher.flushItem(broker, item)
}
}

Expand Down Expand Up @@ -220,8 +229,16 @@ export class DesktopRelayService {
this.refreshDemand({ skipLinger: true })
}

// Terminal: no door re-arms this. Quit calls it (main-process-quit.ts
// before-quit). The re-armable counterpart is fenceAndCloseNow, which quit
// must not use — a settling mint or an invite expiry re-arms that one.
Comment on lines +232 to +234

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quit call-site cross-references are now stale. This comment says quit calls stop() from before-quit, and the sibling comment on fenceAndCloseNow (desktop-relay-service.ts:120) still says quit deliberately does not use the fence. 77d8b28aa2 did the opposite of both: before-quit is back on the fence and the terminal stop() moved to will-quit. These are the comments the PR leans on to keep the split legible, so they should describe the new arrangement.

Suggested change
// Terminal: no door re-arms this. Quit calls it (main-process-quit.ts
// before-quit). The re-armable counterpart is fenceAndCloseNow, which quit
// must not use — a settling mint or an invite expiry re-arms that one.
// Terminal: no door re-arms this. Quit's committed handler calls it
// (main-process-quit.ts will-quit); before-quit only fences. The re-armable
// counterpart is fenceAndCloseNow, which a settling mint or invite expiry re-arms.

stop(): void {
this.stopped = true
this.clearTimers()
this.coordinator.stop()
}

private clearTimers(): void {
if (this.demandExpiryTimer) {
clearTimeout(this.demandExpiryTimer)
this.demandExpiryTimer = null
Expand All @@ -230,13 +247,6 @@ export class DesktopRelayService {
clearInterval(this.livenessTimer)
this.livenessTimer = null
}
this.coordinator.stop()
}

private async flushRevokeOutbox(broker: RelaySessionBroker): Promise<void> {
for (const item of this.revokeOutbox.pendingFor(broker.ownerIdentityKey, broker.hostId)) {
await this.flushRevoke(broker, item)
}
}

private requireMobileDevice(deviceId: string): void {
Expand All @@ -257,20 +267,6 @@ export class DesktopRelayService {
}
}

private async flushRevoke(
broker: RelaySessionBroker,
item: RelayRevokeOutboxItem
): Promise<void> {
try {
await broker.revokeDevice(item.relayDeviceId, item.reqId)
this.revokeOutbox.remove(item.reqId)
this.refreshDemand()
} catch {
// Why: the durable item is the source of truth; reconnecting the same
// account/control retries this stable reqId without delaying local revoke.
}
}

// Restrictive-only: the host setting withdraws Relay from an `automatic`
// device, never grants it to a `local-only` one.
private isRelayAllowedForDevice(deviceId: string): boolean {
Expand Down Expand Up @@ -327,7 +323,7 @@ export class DesktopRelayService {
}

private refreshDemand(options?: { skipLinger?: boolean }): void {
if (this.stopped) {
if (this.stopped || this.fenced) {
return
}
if (!this.livenessTimer) {
Expand Down
9 changes: 8 additions & 1 deletion src/main/runtime/relay/relay-auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,15 @@ export async function readRelayAuthContext(
// Why: refresh and org-selection can rewrite cloud linkage while the request
// is in flight; identity must come from the post-refresh profile state.
const refreshed = ensureActiveOrcaProfile(userDataPath)
// Why throw rather than return null, same argument as the unreadable session above:
// a profile switch landing inside this read is not a sign-out, and null spends the
// terminal SIGNED_OUT — latched by every paired phone, arming no retry — on a race
// the switch's own auth mutation re-reads moments later.
if (refreshed.profile.id !== active.profile.id) {
throw new Error('orca_profile_switched_during_read')
}
const cloud = refreshed.profile.cloud
if (!cloud || refreshed.profile.id !== active.profile.id) {
if (!cloud) {
return null
}
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest'
import { RELAY_HOST_CLOSE_REASON } from '../../../shared/relay-host-close-reason'

// Same taxonomy question 8972d744 answered for the session file, asked of the
// other null exit: readRelayAuthContext re-reads the profile after the refresh,
// and a profile switch landing in that window is not a sign-out.
const fakes = vi.hoisted(() => ({
ensureActiveOrcaProfile: vi.fn(),
readFreshOrcaCloudSession: vi.fn()
}))

vi.mock('../../orca-profiles/profile-index-store', () => ({
ensureActiveOrcaProfile: fakes.ensureActiveOrcaProfile
}))
vi.mock('../../orca-profiles/profile-cloud-session-refresh', () => ({
readFreshOrcaCloudSession: fakes.readFreshOrcaCloudSession
}))

import { readRelayAuthContext } from './relay-auth-context'
import { RelayAuthCoordinator } from './relay-auth-coordinator'

const authConfig = {} as never

function profile(id: string, cloud: object | null) {
return { profile: { id, ...(cloud ? { cloud } : {}) } }
}

const signedIn = { userId: 'u1', cloudProfileId: 'cp1', activeOrgId: 'org-1' }

function foundSession() {
return {
status: 'found',
session: { accessToken: 'access-1', capabilities: { flags: { 'relay.use': true } } }
}
}

describe('readRelayAuthContext profile-switch race', () => {
it('refuses to call a mid-read profile switch a sign-out', async () => {
// ensureActiveOrcaProfile is called twice — once before the refresh and once
// after, because refresh and org selection can rewrite cloud linkage. A
// switch between them means "re-read", not "the cloud session is gone".
fakes.ensureActiveOrcaProfile
.mockReturnValueOnce(profile('profile-1', signedIn))
.mockReturnValueOnce(profile('profile-2', signedIn))
fakes.readFreshOrcaCloudSession.mockResolvedValue(foundSession())

await expect(readRelayAuthContext(authConfig, '/tmp/x')).rejects.toThrow(
'orca_profile_switched_during_read'
)
})

it('classifies the switch as auth_unavailable, never signed_out', async () => {
fakes.ensureActiveOrcaProfile
.mockReturnValueOnce(profile('profile-1', signedIn))
.mockReturnValueOnce(profile('profile-2', signedIn))
fakes.readFreshOrcaCloudSession.mockResolvedValue(foundSession())
const broker = { closeNow: vi.fn() }
const coordinator = new RelayAuthCoordinator({
readContext: () => readRelayAuthContext(authConfig, '/tmp/x'),
openBroker: async () => broker,
onStatus: vi.fn()
})
coordinator.reconcile()

const result = await coordinator.waitForLiveBrokerResult(0)
expect(result).toEqual({ broker: null, offlineReason: 'auth_unavailable' })
// SIGNED_OUT is terminal on the wire — the phone latches it and arms no retry.
expect(broker.closeNow).not.toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})

it('still reports a profile that genuinely has no cloud linkage as gone', async () => {
fakes.ensureActiveOrcaProfile
.mockReturnValueOnce(profile('profile-1', signedIn))
.mockReturnValueOnce(profile('profile-1', null))
fakes.readFreshOrcaCloudSession.mockResolvedValue(foundSession())

await expect(readRelayAuthContext(authConfig, '/tmp/x')).resolves.toBeNull()
})

it('returns the post-refresh identity when the profile held still', async () => {
fakes.ensureActiveOrcaProfile
.mockReturnValueOnce(profile('profile-1', signedIn))
.mockReturnValueOnce(profile('profile-1', { ...signedIn, activeOrgId: 'org-2' }))
fakes.readFreshOrcaCloudSession.mockResolvedValue(foundSession())

await expect(readRelayAuthContext(authConfig, '/tmp/x')).resolves.toEqual({
identity: { userId: 'u1', profileId: 'cp1', organizationId: 'org-2' },
accessToken: 'access-1',
relayEntitled: true
})
})
})
Loading