feat(relay): tell the phone when its desktop is signed out (#18698)

On 2026-09-04 an auth outage signed ~21,600 desktops out of Orca Cloud and
every paired phone showed the generic "Can't reach desktop" for hours. The
desktop knew why, the cell watched it happen, and neither could say so.

The desktop now names auth loss on its control close reason; the cell
remembers that reason per (userId, relayHostId) and replays it as the close
reason of the 4404 it already sends a phone whose host is absent; the phone
turns it into "Desktop signed out — sign in to Orca on your desktop to
reconnect". Retry cadence, close codes and every message body are untouched.

The reason rides the WebSocket close reason because there is no additive JSON
channel to a shipped phone: RelayPhoneHelloSchema, RelayAuthSchema and the
director's ResolveResponseSchema are all zod .strict(), and /v1/connect
rejects any query string outright. A new close code was also rejected — an old
phone would fall out of mobileRelayRecoveryFor and off the 5-15s host-offline
backoff onto the faster transport backoff.

The cell keeps the reason in memory rather than Postgres: a phone reaches the
cell its host's assignment row already names, which is the cell that saw the
close, and losing it on a cell restart degrades to today's verdict rather than
a wrong one.
This commit is contained in:
Jinwoo Hong
2026-09-04 16:51:49 -04:00
committed by GitHub
parent 77334e7c8b
commit ef428d879e
40 changed files with 1164 additions and 57 deletions
@@ -0,0 +1,82 @@
import { ASSIGNMENT_LIMITS, RELAY_HOST_CLOSE_REASON } from '@orca-cloud/relay-contract'
import { describe, expect, it } from 'vitest'
import { HostCloseReasonMemory } from './host-close-reason-memory.js'
function memoryAt(clock: { now: number }): HostCloseReasonMemory {
return new HostCloseReasonMemory(() => clock.now)
}
describe('HostCloseReasonMemory', () => {
it('remembers only reasons it knows', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
memory.record('b', 'quitting')
memory.record('c', Buffer.alloc(0))
memory.record('d', undefined)
expect(memory.read('a')).toBe(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
expect(memory.read('b')).toBeNull()
expect(memory.read('c')).toBeNull()
expect(memory.read('d')).toBeNull()
})
it('accepts the reason as the Buffer a ws close delivers', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', Buffer.from(RELAY_HOST_CLOSE_REASON.SIGNED_OUT))
expect(memory.read('a')).toBe(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})
it('expires an entry once its host may have been rebalanced away', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
clock.now += ASSIGNMENT_LIMITS.dormantTtlMs - 1
expect(memory.read('a')).toBe(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
clock.now += 1
expect(memory.read('a')).toBeNull()
expect(memory.size()).toBe(0)
})
it('forgets on demand', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
memory.forget('a')
expect(memory.read('a')).toBeNull()
})
it('drops the oldest survivors rather than growing without bound', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
for (let index = 0; index < 50_050; index++) {
memory.record(`host-${index}`, RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
}
expect(memory.size()).toBe(50_000)
expect(memory.read('host-0')).toBeNull()
expect(memory.read('host-50049')).toBe(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})
it('re-recording refreshes recency so a live host is not evicted first', () => {
const clock = { now: 1_000 }
const memory = memoryAt(clock)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
memory.record('b', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
memory.record('a', RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
expect([...['a', 'b'].map((key) => memory.read(key))]).toEqual([
RELAY_HOST_CLOSE_REASON.SIGNED_OUT,
RELAY_HOST_CLOSE_REASON.SIGNED_OUT
])
expect(memory.size()).toBe(2)
})
})
@@ -0,0 +1,72 @@
import {
ASSIGNMENT_LIMITS,
relayHostCloseReasonFrom,
type RelayHostCloseReason
} from '@orca-cloud/relay-contract'
// Retention matches the dormant assignment TTL: past it the host may have been
// rebalanced onto another cell, so this cell is no longer the one a phone asks.
const RETENTION_MS = ASSIGNMENT_LIMITS.dormantTtlMs
// A fleet-wide auth outage signs out every host at once; the cap bounds that
// burst well above any single cell's host count without becoming a leak.
const MAX_ENTRIES = 50_000
// Why in-memory and not Postgres: a phone reaches the cell its host's assignment
// row already names, which is the same cell that watched the control socket
// close. Losing this on a cell restart degrades to the pre-existing generic
// verdict, so the failure mode is the old behaviour rather than a wrong one.
export class HostCloseReasonMemory {
private readonly entries = new Map<string, { reason: RelayHostCloseReason; expiresAt: number }>()
constructor(private readonly now: () => number = Date.now) {}
// Silently ignores anything that is not a known reason, which is every close
// from a host that predates the field and every abrupt 1006.
record(key: string, reason: unknown): void {
const parsed = relayHostCloseReasonFrom(reason)
if (!parsed) {
return
}
this.entries.delete(key)
this.entries.set(key, { reason: parsed, expiresAt: this.now() + RETENTION_MS })
this.evict()
}
forget(key: string): void {
this.entries.delete(key)
}
read(key: string): RelayHostCloseReason | null {
const entry = this.entries.get(key)
if (!entry) {
return null
}
if (entry.expiresAt <= this.now()) {
this.entries.delete(key)
return null
}
return entry.reason
}
size(): number {
return this.entries.size
}
private evict(): void {
const now = this.now()
for (const [key, entry] of this.entries) {
if (entry.expiresAt > now) {
break
}
this.entries.delete(key)
}
// Insertion order is recency order (record deletes before setting), so the
// head is always the oldest survivor.
for (const key of this.entries.keys()) {
if (this.entries.size <= MAX_ENTRIES) {
break
}
this.entries.delete(key)
}
}
}
+34 -6
View File
@@ -14,7 +14,8 @@ import {
HostHelloSchema,
InviteCreateSchema,
RELAY_PROTOCOL_LIMITS,
RELAY_CLOSE_CODE
RELAY_CLOSE_CODE,
type RelayHostCloseReason
} from '@orca-cloud/relay-contract'
import nacl from 'tweetnacl'
import type WebSocket from 'ws'
@@ -25,6 +26,7 @@ import {
RelayCredentialStore,
type CredentialReservation
} from './credential-store.js'
import { HostCloseReasonMemory } from './host-close-reason-memory.js'
import { relayHostLogDigest } from './relay-host-log-digest.js'
import type { RelayTokenClaims } from './relay-token-verifier.js'
import type { RelayRuntimeObserver } from './relay-observability.js'
@@ -130,6 +132,10 @@ const ACTIVATION_QUEUE_WAIT_MS = 30_000
export class HostSessionRegistry {
private readonly sessions = new Map<string, HostSession>()
private readonly activationQueues = new Map<string, Promise<void>>()
// Why it outlives `sessions`: the orphan grace deletes the session within 30s,
// but a signed-out desktop never comes back, so the phone that asks minutes
// later would otherwise find nothing to explain its rejection with.
private readonly hostCloseReasons = new HostCloseReasonMemory(() => this.now())
private draining = false
constructor(
@@ -175,7 +181,8 @@ export class HostSessionRegistry {
return
}
this.observer.recordAuth(true)
const session = this.sessions.get(this.key(reservation.userId, hostId))
const sessionKey = this.key(reservation.userId, hostId)
const session = this.sessions.get(sessionKey)
if (
!session ||
session.state !== 'active' ||
@@ -184,7 +191,13 @@ export class HostSessionRegistry {
) {
capacityReservation?.release()
await this.store.failReservation(reservation)
this.rejectClient(socket, RELAY_CLOSE_CODE.HOST_OFFLINE)
// The only rejection that can name a cause: the host is genuinely absent.
// The attach-deadline 4404 below fires while control is still connected.
this.rejectClient(
socket,
RELAY_CLOSE_CODE.HOST_OFFLINE,
this.hostCloseReasons.read(sessionKey)
)
return
}
if (session.activeConnIds.size + session.pendingConns.size >= 8) {
@@ -793,7 +806,10 @@ export class HostSessionRegistry {
regionalDrainTimer: null,
regionalDrainExpiresAt: null
}
this.sessions.set(this.key(identity.sub, identity.relayHostId), session)
const sessionKey = this.key(identity.sub, identity.relayHostId)
// A host that proved itself again is not signed out, whatever it said last.
this.hostCloseReasons.forget(sessionKey)
this.sessions.set(sessionKey, session)
this.wireActiveControl(session)
this.sendHelloAck(session)
}
@@ -813,6 +829,11 @@ export class HostSessionRegistry {
})
socket.once('close', (code, reason) => {
this.observer.recordControlClose?.(code)
// Guarded on identity: a predecessor retired by a rebind must not stamp a
// cause onto the live session that replaced it.
if (session.socket === socket) {
this.hostCloseReasons.record(this.key(session.identity.sub, session.relayHostId), reason)
}
// One line per control close makes reconnect churners attributable by
// host digest without exposing the raw relay host id.
console.warn(
@@ -1187,9 +1208,16 @@ export class HostSessionRegistry {
if (session.socket) send(session.socket, 'control-error', { ...(reqId ? { reqId } : {}), code })
}
private rejectClient(socket: WebSocket, code: number): void {
// hostCloseReason rides the WebSocket close reason, never relay-hello: every
// shipped phone parses relay-hello with a strict schema that rejects an
// unknown key, and none of them read the close reason at all.
private rejectClient(
socket: WebSocket,
code: number,
hostCloseReason?: RelayHostCloseReason | null
): void {
send(socket, 'relay-hello', { ok: false, code })
closeRelayWebSocket(socket, code, 'relay connection rejected')
closeRelayWebSocket(socket, code, hostCloseReason ?? 'relay connection rejected')
}
private releaseControlActivity(session: HostSession): void {
@@ -0,0 +1,206 @@
import { EventEmitter } from 'node:events'
import {
CONTROL_CONTINUITY_LIMITS,
RELAY_CLOSE_CODE,
RELAY_HOST_CLOSE_REASON
} from '@orca-cloud/relay-contract'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type WebSocket from 'ws'
import type { RelayAssignmentStore } from './assignment-store.js'
import type { RelayConfig } from './config.js'
import type { RelayCredentialStore } from './credential-store.js'
import { HostSessionRegistry } from './host-session-registry.js'
import type { RelayRuntimeObserver } from './relay-observability.js'
import type { RelayTokenClaims } from './relay-token-verifier.js'
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
class FakeSocket extends EventEmitter {
readonly OPEN = 1
readonly CLOSED = 3
readyState = this.OPEN
readonly send = vi.fn()
readonly close = vi.fn((code?: number, reason?: string) => {
this.readyState = this.CLOSED
this.emit('close', code, Buffer.from(reason ?? ''))
})
readonly terminate = vi.fn(() => {
this.readyState = this.CLOSED
this.emit('close', 1006, Buffer.alloc(0))
})
}
const config = {
port: 8080,
publicUrl: 'https://relay-c3.example.com',
cellUrl: 'https://relay-c3.example.com',
authIssuer: 'https://auth.example.com',
authAudience: 'orca-relay',
jwksUrl: 'https://auth.example.com/jwks',
assignmentSigningKey: new Uint8Array(32),
role: 'cell',
cellId: 'production-gce-c3',
cells: []
} as unknown as RelayConfig
const identity = {
sub: 'user-1',
prof: 'profile-1',
org: 'org-1',
relayHostId: 'AbCdEf0123_-xyZ9'
} as unknown as RelayTokenClaims
const reservation = {
userId: identity.sub,
relayHostId: identity.relayHostId,
credentialKind: 'resume',
relayDeviceId: 'device-1',
leaseExpiresAt: Date.now() + 60_000
}
function createRegistry() {
const store = {
resolveResume: vi.fn().mockResolvedValue({ userId: identity.sub }),
reserveCredential: vi.fn().mockResolvedValue(reservation),
failReservation: vi.fn().mockResolvedValue(undefined)
}
const assignments = {
activateControl: vi.fn().mockResolvedValue('control:production-gce-c3:1'),
markMigrationTargetRegistered: vi.fn().mockResolvedValue(undefined),
resolve: vi.fn().mockResolvedValue({ cellId: config.cellId }),
acquireActivity: vi.fn().mockResolvedValue(undefined),
renewControlActivity: vi.fn().mockResolvedValue(undefined),
releaseActivity: vi.fn().mockResolvedValue(true)
} as unknown as RelayAssignmentStore
const observer = {
recordAuth: vi.fn(),
recordForwardedBytes: vi.fn(),
recordHttp: vi.fn(),
recordReconnect: vi.fn(),
recordSql: vi.fn(),
recordControlClose: vi.fn(),
recordSpliceClose: vi.fn()
} satisfies RelayRuntimeObserver
const registry = new HostSessionRegistry(
config,
vi.fn(),
store as unknown as RelayCredentialStore,
assignments,
new ProcessQueuedByteBudget(),
observer
)
const activate = (socket: WebSocket, generation: number): Promise<void> =>
(
registry as unknown as {
activate: (
socket: WebSocket,
identity: RelayTokenClaims,
existing: null,
generation: number,
rebind: boolean,
assignmentEpoch: number,
appVersion: string
) => Promise<void>
}
).activate(socket, identity, null, generation, false, 1, '1.4.173')
return { registry, activate }
}
async function dialPhone(registry: HostSessionRegistry): Promise<FakeSocket> {
const phone = new FakeSocket()
await registry.acceptClient(phone as unknown as WebSocket, identity.relayHostId, 'credential')
return phone
}
// The 4404 hello body is unchanged: every shipped phone parses it with a strict
// schema, so the cause has to ride the close frame instead.
const HOST_OFFLINE_HELLO = JSON.stringify({
type: 'relay-hello',
ok: false,
code: RELAY_CLOSE_CODE.HOST_OFFLINE
})
describe('host sign-out reason on phone rejection', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('names the sign-out to a phone that arrives after the host is gone', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
control.close(1000, RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const phone = await dialPhone(registry)
expect(phone.send).toHaveBeenCalledWith(HOST_OFFLINE_HELLO)
expect(phone.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.HOST_OFFLINE,
RELAY_HOST_CLOSE_REASON.SIGNED_OUT
)
})
it('says nothing when the host died without naming a cause', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
control.terminate()
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const phone = await dialPhone(registry)
expect(phone.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.HOST_OFFLINE,
'relay connection rejected'
)
})
it('ignores a close reason the host invented', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
control.close(1000, 'signed-out-ish')
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const phone = await dialPhone(registry)
expect(phone.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.HOST_OFFLINE,
'relay connection rejected'
)
})
it('forgets the sign-out once the host proves itself again', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
control.close(1000, RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const reconnected = new FakeSocket()
await activate(reconnected as unknown as WebSocket, 2)
// Drop it abruptly, as a network death would, so only the stale memory
// could still name a cause.
reconnected.terminate()
vi.advanceTimersByTime(CONTROL_CONTINUITY_LIMITS.orphanGraceMs + 1)
const phone = await dialPhone(registry)
expect(phone.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.HOST_OFFLINE,
'relay connection rejected'
)
})
// A live host is present: the 4404 there is an attach deadline, not absence.
it('never names a cause while the host control is connected', async () => {
const { registry, activate } = createRegistry()
const control = new FakeSocket()
await activate(control as unknown as WebSocket, 1)
const phone = await dialPhone(registry)
expect(phone.close).not.toHaveBeenCalled()
expect(control.send).toHaveBeenCalledWith(expect.stringContaining('"type":"conn-open"'))
})
})
@@ -0,0 +1,18 @@
// Mirror of src/shared/relay-host-close-reason.ts in the Orca app repo half.
// A host control socket may close with one of these as its WebSocket close
// reason; the cell records it so a later phone rejection can name the cause.
// Anything else (including the empty reason of an abrupt 1006) means "unknown",
// which is what every peer that predates this file sends.
export const RELAY_HOST_CLOSE_REASON = {
SIGNED_OUT: 'signed-out'
} as const
export type RelayHostCloseReason =
(typeof RELAY_HOST_CLOSE_REASON)[keyof typeof RELAY_HOST_CLOSE_REASON]
const REASONS: readonly string[] = Object.values(RELAY_HOST_CLOSE_REASON)
export function relayHostCloseReasonFrom(value: unknown): RelayHostCloseReason | null {
const text = typeof value === 'string' ? value : (value?.toString() ?? '')
return REASONS.includes(text) ? (text as RelayHostCloseReason) : null
}
@@ -5,6 +5,7 @@ export * from './control-messages.js'
export * from './control-continuity.js'
export * from './credential-messages.js'
export * from './director-messages.js'
export * from './host-close-reason.js'
export * from './host-proof-transcript.js'
export * from './persistence-invariants.js'
export * from './protocol-limits.js'
+6 -1
View File
@@ -19,6 +19,7 @@ type MobileHomeHostListProps = {
hostAttempts: Record<string, number>
hostLastConnected: Record<string, number | null>
hostPairingRejected: Record<string, boolean>
hostSignedOut: Record<string, boolean>
hostPaths: Record<string, MobileConnectionPath>
hostPendingPaths: Record<string, MobileConnectionPath | null>
hosts: HostCatalogEntry[]
@@ -40,6 +41,7 @@ export function MobileHomeHostList(props: MobileHomeHostListProps) {
hostAttempts={props.hostAttempts}
hostLastConnected={props.hostLastConnected}
hostPairingRejected={props.hostPairingRejected}
hostSignedOut={props.hostSignedOut}
hostPaths={props.hostPaths}
hostPendingPaths={props.hostPendingPaths}
hostStates={props.hostStates}
@@ -54,6 +56,7 @@ export function MobileHomeHostList(props: MobileHomeHostListProps) {
props.hostAttempts,
props.hostLastConnected,
props.hostPairingRejected,
props.hostSignedOut,
props.hostPaths,
props.hostPendingPaths,
props.hostStates,
@@ -91,6 +94,7 @@ type MobileHomeHostRowProps = Pick<
| 'hostAttempts'
| 'hostLastConnected'
| 'hostPairingRejected'
| 'hostSignedOut'
| 'hostPaths'
| 'hostPendingPaths'
| 'hostStates'
@@ -113,7 +117,8 @@ const MobileHomeHostRow = memo(function MobileHomeHostRow(props: MobileHomeHostR
lastConnectedAt: props.hostLastConnected[item.id] ?? null,
endpoint: item.endpoint,
pendingPath: props.hostPendingPaths[item.id] ?? null,
pairingRejected: props.hostPairingRejected[item.id] ?? false
pairingRejected: props.hostPairingRejected[item.id] ?? false,
hostSignedOut: props.hostSignedOut[item.id] ?? false
})
const open = useCallback(() => onOpen(item), [item, onOpen])
const longPress = useCallback(() => onLongPress(item), [item, onLongPress])
+1
View File
@@ -143,6 +143,7 @@ export function MobileHomeScreen() {
hostAttempts={data.hostAttempts}
hostLastConnected={data.hostLastConnected}
hostPairingRejected={data.hostPairingRejected}
hostSignedOut={data.hostSignedOut}
hostPaths={data.hostPaths}
hostPendingPaths={data.hostPendingPaths}
hosts={data.sortedHostCatalog}
@@ -5,12 +5,14 @@ export type HomeHostConnectionProjectionEntry = {
path: MobileConnectionPath
pendingPath: MobileConnectionPath | null
pairingRejected: boolean
hostSignedOut: boolean
}
export type HomeHostConnectionProjection = {
hostPaths: Record<string, MobileConnectionPath>
hostPendingPaths: Record<string, MobileConnectionPath | null>
hostPairingRejected: Record<string, boolean>
hostSignedOut: Record<string, boolean>
}
/** Build all host lookup maps while reading each connection entry once. */
@@ -22,16 +24,19 @@ export function projectHomeHostConnections(
const hostPaths = Object.create(null) as Record<string, MobileConnectionPath>
const hostPendingPaths = Object.create(null) as Record<string, MobileConnectionPath | null>
const hostPairingRejected = Object.create(null) as Record<string, boolean>
const hostSignedOut = Object.create(null) as Record<string, boolean>
for (const { hostId, path, pendingPath, pairingRejected } of entries) {
for (const { hostId, path, pendingPath, pairingRejected, hostSignedOut: signedOut } of entries) {
hostPaths[hostId] = path
hostPendingPaths[hostId] = pendingPath
hostPairingRejected[hostId] = pairingRejected
hostSignedOut[hostId] = signedOut
}
Object.setPrototypeOf(hostPaths, Object.prototype)
Object.setPrototypeOf(hostPendingPaths, Object.prototype)
Object.setPrototypeOf(hostPairingRejected, Object.prototype)
Object.setPrototypeOf(hostSignedOut, Object.prototype)
return { hostPaths, hostPendingPaths, hostPairingRejected }
return { hostPaths, hostPendingPaths, hostPairingRejected, hostSignedOut }
}
+1
View File
@@ -179,6 +179,7 @@ export function useMobileHomeData() {
connectedHosts,
hostCatalog,
hostPairingRejected: hostConnectionProjection.hostPairingRejected,
hostSignedOut: hostConnectionProjection.hostSignedOut,
hostPaths: hostConnectionProjection.hostPaths,
hostPendingPaths: hostConnectionProjection.hostPendingPaths,
primaryHost,
@@ -30,14 +30,16 @@ export function useConnectionPathStatus(hostId: string | undefined): {
export function useRelayRecoveryStatus(hostId: string | undefined): {
pendingPath: MobileConnectionPath | null
pairingRejected: boolean
hostSignedOut: boolean
} {
return useHostMetric(
hostId,
(context, id) => ({
pendingPath: context.getPendingPath(id),
pairingRejected: context.isPairingRejected(id)
pairingRejected: context.isPairingRejected(id),
hostSignedOut: context.isHostSignedOut(id)
}),
{ pendingPath: null, pairingRejected: false }
{ pendingPath: null, pairingRejected: false, hostSignedOut: false }
)
}
+2 -2
View File
@@ -533,12 +533,12 @@ describe('useAllHostClients', () => {
await Promise.resolve()
})
act(() => client.emitPendingPath('relay'))
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: false })
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: false, hostSignedOut: false })
// Why: the desktop refusing the credential is a status-only change — no
// transport state moves, so only the connection-path signal can carry it.
act(() => client.emitPairingRejected(true))
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: true })
expect(status).toEqual({ pendingPath: 'relay', pairingRejected: true, hostSignedOut: false })
act(() => renderer.unmount())
})
+19
View File
@@ -29,6 +29,10 @@ const STALE_SINCE_LAST_CONNECT_MS = 60_000
// instead of leaving the user staring at a generic "Can't connect".
const TAILSCALE_HINT = 'check Tailscale'
// No hint field: the remedy is the label, and appending "— check Tailscale" to
// it would be wrong advice for a desktop that is reachable but signed out.
const SIGNED_OUT_LABEL = 'Desktop signed out — sign in to Orca on your desktop to reconnect'
export type ConnectionVerdict =
| { kind: 'normal'; label: string }
| { kind: 'warning'; label: string; hint?: string } // "Can't connect"
@@ -54,6 +58,10 @@ export function classifyConnection(args: {
// The desktop has repeatedly refused this device's relay credential — retrying
// cannot fix it, so it outranks any "still connecting" reading (STA-4681).
pairingRejected?: boolean
// The relay says the desktop's last control close named its own Orca Cloud
// sign-out. Retrying is still correct and still happens on the same cadence,
// but only the desktop's owner can end it, so the label has to say so.
hostSignedOut?: boolean
nowMs?: number
}): ConnectionVerdict {
const { state, reconnectAttempts, lastConnectedAt } = args
@@ -70,6 +78,17 @@ export function classifyConnection(args: {
return { kind: 'normal', label: 'Connected' }
}
// Ahead of the attempt thresholds: this is evidence, not an inference from a
// failure streak, and waiting twelve dials to show it wastes the whole point.
// Below auth-failed because a revoked pairing cannot be fixed by signing in.
if (args.hostSignedOut) {
return {
kind: 'unreachable',
label: SIGNED_OUT_LABEL,
reason: lastConnectedAt == null ? 'never-connected' : 'stale'
}
}
// A disconnected pending path can survive a cleared retry timer during a
// lifecycle race. Only narrate Relay while dialing or after a retry has
// recorded progress; otherwise the idle transport must read Disconnected.
@@ -89,10 +89,16 @@ export function createHostClientSelectors(
getPendingPath: (hostId: string): MobileConnectionPath | null =>
clientPendingPath(entries.get(hostId)?.client),
isPairingRejected: (hostId: string): boolean =>
clientPairingRejected(entries.get(hostId)?.client)
clientPairingRejected(entries.get(hostId)?.client),
isHostSignedOut: (hostId: string): boolean => clientHostSignedOut(entries.get(hostId)?.client)
}
}
export function clientHostSignedOut(client: RpcClient | undefined): boolean {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return logical?.isHostSignedOut?.() ?? false
}
export function clientPairingRejected(client: RpcClient | undefined): boolean {
const logical = client as Partial<StableLogicalRpcClient> | undefined
return logical?.isPairingRejected?.() ?? false
@@ -5,6 +5,7 @@ export class LogicalClientConnectionPath {
private recovery: MobileConnectionPath | null = null
private recoveryAttempt = 0
private pairingRejected = false
private hostSignedOut = false
private readonly listeners = new Set<() => void>()
constructor(private readonly isConnected: () => boolean) {}
@@ -35,12 +36,23 @@ export class LogicalClientConnectionPath {
})
}
isHostSignedOut(): boolean {
return this.hostSignedOut
}
setHostSignedOut(signedOut: boolean): void {
this.update(() => {
this.hostSignedOut = signedOut
})
}
clearAfterConnected(): void {
this.migration = null
this.recovery = null
this.recoveryAttempt = 0
// Why: an authenticated session is the desktop accepting this device.
this.pairingRejected = false
this.hostSignedOut = false
}
setRecovery(path: MobileConnectionPath | null, attempt?: number): void {
@@ -69,11 +81,13 @@ export class LogicalClientConnectionPath {
const previousPath = this.pending()
const previousAttempt = this.reconnectAttempt(0)
const previousRejected = this.pairingRejected
const previousSignedOut = this.hostSignedOut
apply()
if (
previousPath === this.pending() &&
previousAttempt === this.reconnectAttempt(0) &&
previousRejected === this.pairingRejected
previousRejected === this.pairingRejected &&
previousSignedOut === this.hostSignedOut
) {
return
}
@@ -86,7 +86,7 @@ function createSupervisor(
): MobileEndpointSupervisor {
return new MobileEndpointSupervisor(logical, host, {
openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
openRelay: (relay, credential, confirmReqId) =>
openRelay: (relay, credential, confirmReqId, onHostCloseReason) =>
connectMobileRelayRpcSession({
relay,
resumeToken: credential.token,
@@ -94,6 +94,7 @@ function createSupervisor(
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
desktopPublicKeyB64: host.publicKeyB64,
onHostCloseReason,
onLog
}),
resolveRelay: resolveMobileRelayEndpoint,
@@ -1,4 +1,5 @@
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import type { RelayHostCloseReason } from '../../../src/shared/relay-host-close-reason'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director'
@@ -10,7 +11,8 @@ export type MobileEndpointSupervisorDependencies = {
openRelay: (
relay: MobileRelayEndpoint,
credential: { token: string; version: number },
confirmReqId: string
confirmReqId: string,
onHostCloseReason?: (reason: RelayHostCloseReason) => void
) => MobileRelayRpcSession
resolveRelay: typeof resolveMobileRelayEndpoint
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
@@ -134,10 +134,22 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
}
})
isPairingRejected = () => this.pairingRejected
private hostSignedOut = false
setHostSignedOut = vi.fn((signedOut: boolean) => {
if (this.hostSignedOut === signedOut) {
return
}
this.hostSignedOut = signedOut
for (const listener of this.pathListeners) {
listener()
}
})
isHostSignedOut = () => this.hostSignedOut
// Mirrors LogicalClientConnectionPath.clearAfterConnected.
publishState(state: ConnectionState): void {
if (state === 'connected') {
this.pairingRejected = false
this.hostSignedOut = false
}
super.publishState(state)
}
@@ -185,7 +185,12 @@ describe('mobile endpoint supervisor', () => {
await supervisor.start()
expect(deps.resolveRelay).toHaveBeenCalledOnce()
expect(openRelay).toHaveBeenLastCalledWith(resolved, expect.any(Object), expect.any(String))
expect(openRelay).toHaveBeenLastCalledWith(
resolved,
expect.any(Object),
expect.any(String),
expect.any(Function)
)
expect(deps.saveHost).toHaveBeenCalledWith(
expect.objectContaining({ relay: resolved, endpoint: host.endpoint })
)
@@ -556,7 +561,8 @@ describe('mobile endpoint supervisor', () => {
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 3 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
supervisor.stop()
})
@@ -603,7 +609,8 @@ describe('mobile endpoint supervisor', () => {
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 3 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
supervisor.stop()
})
@@ -2,6 +2,10 @@ import {
RelayPhoneHelloSchema,
type RelayPhoneHello
} from '../../../src/shared/mobile-relay-phone-protocol'
import {
relayHostCloseReasonFrom,
type RelayHostCloseReason
} from '../../../src/shared/relay-host-close-reason'
import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel'
import { websocketPayloadToUint8 } from './websocket-payload-bytes'
@@ -26,6 +30,12 @@ type MobileRelayE2eeLinkOptions = {
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onHello?: (hello: Extract<RelayPhoneHello, { ok: true }>) => void
// The cell's account of why the desktop is absent, read off the close frame.
// Reported separately from onError because a rejection is delivered as both a
// relay-hello and a close, and which one the runtime dispatches first is not
// ordered — only the close carries the reason, and it must not be lost to
// that race.
onHostCloseReason?: (reason: RelayHostCloseReason) => void
// Fired once relay-auth is on the wire: from here the cell owns the wait.
onOpen?: () => void
onError: (error: Error) => void
@@ -129,6 +139,11 @@ export class MobileRelayE2eeLink {
clearTimeout(this.transportErrorTimer)
this.transportErrorTimer = null
}
// Ahead of fail(), which no-ops once the hello already reported this close.
const hostCloseReason = relayHostCloseReasonFrom(event.reason)
if (hostCloseReason) {
this.options.onHostCloseReason?.(hostCloseReason)
}
this.fail(new RelayOuterError(event.code || 1006))
}
}
@@ -13,6 +13,7 @@ import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-s
import { RelayPendingRequests } from './relay-pending-requests'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation'
import type { RelayHostCloseReason } from '../../../src/shared/relay-host-close-reason'
import type { RpcClient } from './rpc-client'
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
@@ -40,6 +41,7 @@ export function connectMobileRelayRpcSession(args: {
desktopPublicKeyB64: string
requestTimeoutMs?: number
createSocket?: (url: string) => WebSocket
onHostCloseReason?: (reason: RelayHostCloseReason) => void
onLog?: ConnectionLogSink
}): MobileRelayRpcSession {
const requestTimeoutMs = args.requestTimeoutMs ?? 30_000
@@ -69,6 +71,7 @@ export function connectMobileRelayRpcSession(args: {
deviceToken: args.deviceToken,
desktopPublicKeyB64: args.desktopPublicKeyB64,
createSocket: args.createSocket,
onHostCloseReason: args.onHostCloseReason,
onOpen: () => dialStage.advance('awaiting-hello'),
onHello: (hello) => {
if (
@@ -145,10 +145,22 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
}
})
isPairingRejected = () => this.pairingRejected
private hostSignedOut = false
setHostSignedOut = vi.fn((signedOut: boolean) => {
if (this.hostSignedOut === signedOut) {
return
}
this.hostSignedOut = signedOut
for (const listener of this.pathListeners) {
listener()
}
})
isHostSignedOut = () => this.hostSignedOut
// Mirrors LogicalClientConnectionPath.clearAfterConnected.
publishState(state: ConnectionState): void {
if (state === 'connected') {
this.pairingRejected = false
this.hostSignedOut = false
}
super.publishState(state)
}
@@ -264,7 +276,8 @@ describe('relay runtime recovery without direct connectivity', () => {
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 3 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
@@ -353,7 +366,8 @@ describe('relay runtime recovery without direct connectivity', () => {
expect(deps.openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 2 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
@@ -382,7 +396,8 @@ describe('relay runtime recovery without direct connectivity', () => {
expect(openRelay).toHaveBeenLastCalledWith(
relay,
expect.objectContaining({ version: 1 }),
expect.any(String)
expect.any(String),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
@@ -10,6 +10,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason'
import type { HostProfile } from './types'
type EstablishResult = { ok: true } | { ok: false; error: Error }
@@ -100,7 +101,16 @@ export class MobileRelaySessionEstablisher {
const session = args.openRelay(
relay,
credential,
`confirm-${encodeBase64Url(args.randomBytes(16))}`
`confirm-${encodeBase64Url(args.randomBytes(16))}`,
// Latched on the logical client, not on the dial result: the close that
// carries the reason can land after this dial has already reported its
// failure. Clearing is clearAfterConnected's job, so any path that
// reaches connected retires it.
(reason) => {
if (reason === RELAY_HOST_CLOSE_REASON.SIGNED_OUT) {
args.logical.setHostSignedOut(true)
}
}
)
try {
// Why: backgrounding or a direct winner withdraws this dial before cutover.
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes'
import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import {
dependencies,
FakeLogicalClient,
FakeRelaySession,
host
} 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) }))
// The reason travels from the cell's close frame to the screens. This covers
// the production wiring between them: the supervisor's own openRelay callback.
describe('a signed-out desktop reaches the phone verdict', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => vi.useRealTimers())
function supervisorOver(closeReason: string | null) {
const logical = new FakeLogicalClient('disconnected', 'lan')
const deps = dependencies({
openDirect: vi.fn(() => new FakeRelaySession('disconnected')),
openRelay: vi.fn((_relay, _credential, _confirmReqId, onHostCloseReason) => {
if (closeReason) {
onHostCloseReason?.(closeReason as never)
}
return new FakeRelaySession(
'disconnected',
new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE)
)
})
})
return { logical, supervisor: new MobileEndpointSupervisor(logical, host, deps) }
}
it('latches the sign-out the cell reported', async () => {
const { logical, supervisor } = supervisorOver(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
await supervisor.start()
await vi.waitFor(() => expect(logical.isHostSignedOut()).toBe(true))
supervisor.stop()
})
it('stays quiet for an ordinary host-offline rejection', async () => {
const { logical, supervisor } = supervisorOver(null)
await supervisor.start()
expect(logical.isHostSignedOut()).toBe(false)
supervisor.stop()
})
})
@@ -0,0 +1,216 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./mobile-e2ee-v2-client-session', () => ({
MobileE2EEV2ClientSession: { create: () => ({}) }
}))
vi.mock('./mobile-e2ee-v2-physical-channel', () => ({
MobileE2EEAuthenticationError: class extends Error {},
MobileE2EEV2PhysicalChannel: class {
start = vi.fn()
handleMessage = vi.fn(async () => {})
sendText = vi.fn(() => true)
sendBinary = vi.fn(() => true)
dispose = vi.fn()
}
}))
import { RELAY_HOST_CLOSE_REASON } from '../../../src/shared/relay-host-close-reason'
import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes'
import { classifyConnection, verdictDisplayLabel } from './connection-health'
import { MobileRelayE2eeLink, RelayOuterError } from './mobile-relay-e2ee-link'
import { LogicalClientConnectionPath } from './logical-client-connection-path'
import { RelayReconnectController } from './mobile-relay-reconnect-controller'
const SIGNED_OUT_LABEL = 'Desktop signed out — sign in to Orca on your desktop to reconnect'
class FakeSocket {
static readonly OPEN = 1
readonly OPEN = FakeSocket.OPEN
readyState = FakeSocket.OPEN
bufferedAmount = 0
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number; reason: string }) => void) | null = null
send = vi.fn()
close = vi.fn()
}
function linkOver(
socket: FakeSocket,
onHostCloseReason: (reason: string) => void,
onError: (error: Error) => void
): MobileRelayE2eeLink {
return new MobileRelayE2eeLink({
endpoint: { cellUrl: 'https://relay-c1.onorca.dev', relayHostId: 'AbCdEf0123_-xyZ9' },
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onHostCloseReason,
onError,
createSocket: () => socket as unknown as WebSocket
})
}
describe('relay close reason on the phone', () => {
it('reports the cell close reason and still fails with 4404', () => {
const socket = new FakeSocket()
const onHostCloseReason = vi.fn()
const onError = vi.fn()
linkOver(socket, onHostCloseReason, onError)
socket.onclose?.({
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT
})
expect(onHostCloseReason).toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
expect(onError).toHaveBeenCalledWith(new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE))
})
// An old cell sends its constant, and every other close sends nothing.
it('reports nothing for a reason it does not know', () => {
const socket = new FakeSocket()
const onHostCloseReason = vi.fn()
linkOver(socket, onHostCloseReason, vi.fn())
socket.onclose?.({
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
reason: 'relay connection rejected'
})
expect(onHostCloseReason).not.toHaveBeenCalled()
})
// The rejection arrives as a relay-hello AND a close, in an unordered pair.
// Whichever lands first, the reason must survive.
it('still reports the reason when the hello already failed the link', async () => {
const socket = new FakeSocket()
const onHostCloseReason = vi.fn()
linkOver(socket, onHostCloseReason, vi.fn())
socket.onmessage?.({
data: JSON.stringify({
type: 'relay-hello',
ok: false,
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE
})
})
await Promise.resolve()
await Promise.resolve()
socket.onclose?.({
code: MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE,
reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT
})
expect(onHostCloseReason).toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})
})
describe('the signed-out signal on the logical client', () => {
it('publishes on change and retires when any path reaches connected', () => {
const path = new LogicalClientConnectionPath(() => false)
const changes = vi.fn()
path.subscribe(changes)
path.setHostSignedOut(true)
path.setHostSignedOut(true)
expect(path.isHostSignedOut()).toBe(true)
expect(changes).toHaveBeenCalledTimes(1)
path.clearAfterConnected()
expect(path.isHostSignedOut()).toBe(false)
})
})
describe('RelayReconnectController cadence', () => {
// The reason changes no recovery decision; 4404 keeps the host-offline
// backoff it has always had, so a phone on this build retries exactly as
// often as one that never hears the reason.
it('keeps the host-offline retry delay for a 4404', () => {
const delays: number[] = []
const controller = new RelayReconnectController(
{
now: () => 0,
randomBytes: () => new Uint8Array([0, 0]),
setTimer: ((callback: () => void, delay: number) => {
delays.push(delay)
return 1 as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout,
clearTimer: (() => {}) as unknown as typeof clearTimeout
},
vi.fn()
)
controller.registerFailure(new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE))
// hostOfflineDelayMs' 5s floor, not the 250ms transport-backoff floor.
expect(delays.at(-1)).toBe(5_000)
})
})
describe('classifyConnection with a signed-out desktop', () => {
const base = { reconnectAttempts: 0, lastConnectedAt: null, hostSignedOut: true }
it('says so from the first failed dial instead of "Connecting via Relay…"', () => {
const verdict = classifyConnection({
...base,
state: 'connecting',
pendingPath: 'relay'
})
expect(verdict).toEqual({
kind: 'unreachable',
label: SIGNED_OUT_LABEL,
reason: 'never-connected'
})
expect(verdictDisplayLabel(verdict)).toBe(SIGNED_OUT_LABEL)
})
it('replaces "Can\'t reach desktop" on the direct path too', () => {
expect(
classifyConnection({ ...base, state: 'reconnecting', reconnectAttempts: 20 }).label
).toBe(SIGNED_OUT_LABEL)
})
it('reads as stale once this session had been connected', () => {
expect(
classifyConnection({ ...base, state: 'reconnecting', lastConnectedAt: 1, nowMs: 2 }).reason
).toBe('stale')
})
// A Tailscale endpoint cannot make "sign in on your desktop" better advice.
it('never appends the Tailscale hint', () => {
expect(
classifyConnection({ ...base, state: 'reconnecting', endpoint: '100.64.0.1' })
).not.toHaveProperty('hint')
})
it('never outranks a connected session', () => {
expect(classifyConnection({ ...base, state: 'connected' }).label).toBe('Connected')
})
// Re-pairing, not signing in, is the remedy when the pairing itself is dead.
it('never outranks a revoked pairing', () => {
expect(classifyConnection({ ...base, state: 'reconnecting', pairingRejected: true }).kind).toBe(
'auth-failed'
)
})
it('leaves every other verdict alone when the desktop is not signed out', () => {
expect(
classifyConnection({
state: 'connecting',
reconnectAttempts: 0,
lastConnectedAt: null,
pendingPath: 'relay',
hostSignedOut: false
}).label
).toBe('Connecting via Relay…')
})
})
@@ -24,6 +24,7 @@ export type RpcClientContextValue = {
getActivePath: (hostId: string) => MobileConnectionPath
getPendingPath: (hostId: string) => MobileConnectionPath | null
isPairingRejected: (hostId: string) => boolean
isHostSignedOut: (hostId: string) => boolean
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
getAllClients: () => { hostId: string; client: RpcClient }[]
subscribeAllHosts: (listener: () => void) => () => void
@@ -55,6 +55,9 @@ export type StableLogicalRpcClient = RpcClient & {
// Latched when the desktop has repeatedly refused this device's relay credential.
setPairingRejected(rejected: boolean): void
isPairingRejected(): boolean
// Latched when the relay named the desktop's own sign-out as the reason it is absent.
setHostSignedOut(signedOut: boolean): void
isHostSignedOut(): boolean
// Recovery attempts share this signal so status-only changes rerender.
onConnectionPathChange(listener: () => void): () => void
getGeneration(): number
@@ -282,6 +285,8 @@ export function createStableLogicalRpcClient(
setRecoveryAttempt: (attempt) => connectionPath.setRecoveryAttempt(attempt),
setPairingRejected: (rejected) => connectionPath.setPairingRejected(rejected),
isPairingRejected: () => connectionPath.isPairingRejected(),
setHostSignedOut: (signedOut) => connectionPath.setHostSignedOut(signedOut),
isHostSignedOut: () => connectionPath.isHostSignedOut(),
onConnectionPathChange: (listener) => connectionPath.subscribe(listener),
getGeneration: () => generation
}
+3 -1
View File
@@ -138,6 +138,7 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
path: MobileConnectionPath
pendingPath: MobileConnectionPath | null
pairingRejected: boolean
hostSignedOut: boolean
}>((hostId) => {
const client = clientsByHostId.get(hostId)
return client
@@ -148,7 +149,8 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
state: ctx.getState(hostId),
path: ctx.getActivePath(hostId),
pendingPath: ctx.getPendingPath(hostId),
pairingRejected: ctx.isPairingRejected(hostId)
pairingRejected: ctx.isPairingRejected(hostId),
hostSignedOut: ctx.isHostSignedOut(hostId)
}
]
: []
@@ -6,6 +6,7 @@ import type {
PairingGetEndpointsResult,
PairingProvisionRelayParams
} from '../../../shared/mobile-relay-credential-contract'
import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason'
import { readRelayAuthContext } from './relay-auth-context'
import { RelayAuthCoordinator } from './relay-auth-coordinator'
import { RelaySessionBroker, type RelayBrokerStatus } from './relay-session-broker'
@@ -112,7 +113,7 @@ export class DesktopRelayService {
this.refreshDemand()
}
fenceAndCloseNow(): void {
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.
@@ -120,7 +121,7 @@ export class DesktopRelayService {
clearInterval(this.livenessTimer)
this.livenessTimer = null
}
this.coordinator.fenceAndCloseNow()
this.coordinator.fenceAndCloseNow(hostCloseReason)
}
async createPairingRelay(
@@ -1,3 +1,7 @@
import {
RELAY_HOST_CLOSE_REASON,
type RelayHostCloseReason
} from '../../../shared/relay-host-close-reason'
import type { RelayBrokerStatus } from './relay-session-broker'
import { RelayHttpError, shouldRetryRelayConnectionError } from './relay-http-client'
@@ -14,7 +18,7 @@ export type RelayAuthContext = {
}
export type CoordinatedRelayBroker = {
closeNow(): void
closeNow(hostCloseReason?: RelayHostCloseReason): void
isLive?(): boolean
}
@@ -78,13 +82,16 @@ export class RelayAuthCoordinator {
void reconcile
}
fenceAndCloseNow(): void {
// hostCloseReason names an auth loss the phone should be told about. Quit,
// relaunch and every other fence pass nothing, so the control socket dies
// abruptly exactly as before and the cell records no cause.
fenceAndCloseNow(hostCloseReason?: RelayHostCloseReason): void {
++this.authEpoch
this.cancelLinger()
this.cancelRetry()
this.retryAttempt = 0
this.invalidatePendingOwnerships()
this.invalidateOwnership()
this.invalidateOwnership(hostCloseReason)
this.options.onStatus('offline')
}
@@ -146,7 +153,11 @@ export class RelayAuthCoordinator {
if (!context || !context.relayEntitled) {
this.cancelLinger()
this.retryAttempt = 0
this.invalidateOwnership()
// Why only the null case: readContext throws on transient failures and
// returns null solely when the cloud session is gone (absent, or cleared
// by a 401). A present-but-unentitled context is still a signed-in
// desktop, and "sign in to reconnect" would be wrong advice for it.
this.invalidateOwnership(context ? undefined : RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
this.options.onStatus('offline')
return
}
@@ -271,12 +282,12 @@ export class RelayAuthCoordinator {
return context.accessToken
}
private invalidateOwnership(): void {
private invalidateOwnership(hostCloseReason?: RelayHostCloseReason): void {
const ownership = this.ownership
this.ownership = null
if (ownership) {
ownership.valid = false
ownership.broker?.closeNow()
ownership.broker?.closeNow(hostCloseReason)
}
}
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import { RELAY_HOST_CLOSE_REASON } from '../../../shared/relay-host-close-reason'
import { RelayAuthCoordinator, type RelayAuthContext } from './relay-auth-coordinator'
const context: RelayAuthContext = {
identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' },
accessToken: 'access-1',
relayEntitled: true
}
function coordinatorOver(readContext: () => Promise<RelayAuthContext | null>) {
const broker = { closeNow: vi.fn() }
const coordinator = new RelayAuthCoordinator({
readContext,
openBroker: async () => broker,
onStatus: vi.fn()
})
return { broker, coordinator }
}
describe('relay control close reason', () => {
it('names the sign-out when the cloud session is gone', async () => {
let current: RelayAuthContext | null = context
const { broker, coordinator } = coordinatorOver(async () => current)
coordinator.reconcile()
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
current = null
coordinator.reconcile()
await coordinator.waitForLiveBroker()
expect(broker.closeNow).toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})
it('names the sign-out on the explicit pre-sign-out fence', async () => {
const { broker, coordinator } = coordinatorOver(async () => context)
coordinator.reconcile()
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
coordinator.fenceAndCloseNow(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
expect(broker.closeNow).toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
})
it('stays silent on quit, which fences without a reason', async () => {
const { broker, coordinator } = coordinatorOver(async () => context)
coordinator.reconcile()
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
coordinator.fenceAndCloseNow()
expect(broker.closeNow).toHaveBeenCalledWith(undefined)
})
it('stays silent on stop, which is teardown rather than auth loss', async () => {
const { broker, coordinator } = coordinatorOver(async () => context)
coordinator.reconcile()
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
coordinator.stop()
expect(broker.closeNow).toHaveBeenCalledWith(undefined)
})
// A signed-in desktop that merely lost the entitlement must not tell the
// phone to sign in — the copy would be wrong and the user has nothing to do.
it('stays silent when the session survives but the entitlement is gone', async () => {
let current: RelayAuthContext = context
const { broker, coordinator } = coordinatorOver(async () => current)
coordinator.reconcile()
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
current = { ...context, relayEntitled: false }
coordinator.reconcile()
await coordinator.waitForLiveBroker()
expect(broker.closeNow).toHaveBeenCalledWith(undefined)
})
it('stays silent when demand drops and the broker lingers out', async () => {
let demanded = true
const broker = { closeNow: vi.fn() }
const coordinator = new RelayAuthCoordinator({
readContext: async () => context,
hasDemand: () => demanded,
openBroker: async () => broker,
onStatus: vi.fn(),
lingerMs: 5
})
coordinator.reconcile()
await expect(coordinator.waitForLiveBroker()).resolves.toBe(broker)
demanded = false
coordinator.reconcile()
await vi.waitFor(() => expect(broker.closeNow).toHaveBeenCalled())
expect(broker.closeNow).toHaveBeenCalledWith(undefined)
})
// Replacing a stale broker is a reconnect, not a sign-out.
it('stays silent when an identity switch replaces the broker', async () => {
let current = context
const brokers: { closeNow: ReturnType<typeof vi.fn> }[] = []
const coordinator = new RelayAuthCoordinator({
readContext: async () => current,
openBroker: async () => {
const broker = { closeNow: vi.fn() }
brokers.push(broker)
return broker
},
onStatus: vi.fn()
})
coordinator.reconcile()
await coordinator.waitForLiveBroker()
current = { ...context, identity: { ...context.identity, organizationId: 'org-2' } }
coordinator.reconcile()
await coordinator.waitForLiveBroker()
expect(brokers[0]?.closeNow).toHaveBeenCalledWith(undefined)
})
})
+9 -15
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'
import WebSocket, { type RawData } from 'ws'
import { MOBILE_RELAY_CLOSE_CODE } from '../../../shared/mobile-relay-close-codes'
import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason'
import type { E2EEKeypair } from '../e2ee-keypair'
import {
RelayConnectionOpenMessageSchema,
@@ -8,6 +9,7 @@ import {
RelayHostChallengeMessageSchema,
RelayHostHelloAckMessageSchema,
RelayPingMessageSchema,
encodeRelayHostHello,
parseRelayControlMessage,
type RelayConnectionOpenMessage,
type RelayDrainMessage,
@@ -21,6 +23,7 @@ import {
RELAY_CONTROL_SILENCE_LIMIT_MS,
RelayControlSilenceWatchdog
} from './relay-control-silence-watchdog'
import { closeRelayControlSocket } from './relay-control-socket-close'
import { controlWebSocketUrl } from './relay-control-url'
type RelayControlState = 'idle' | 'opening' | 'proving' | 'active' | 'draining' | 'closed'
@@ -166,7 +169,7 @@ export class RelayControlClient {
return this.requests.confirmResume(reqId, basisConnId, (payload) => this.sendActive(payload))
}
closeNow(): void {
closeNow(hostCloseReason?: RelayHostCloseReason): void {
const wasConnecting = this.state === 'opening' || this.state === 'proving'
this.state = 'closed'
this.silenceWatchdog.stop()
@@ -175,8 +178,9 @@ export class RelayControlClient {
this.clearConnectPromise()
}
this.requests.rejectAll(new Error('relay_control_closed'))
this.socket?.terminate()
const socket = this.socket
this.socket = null
closeRelayControlSocket(socket, hostCloseReason)
}
private sendHostHello(): void {
@@ -185,19 +189,9 @@ export class RelayControlClient {
}
this.state = 'proving'
this.socket.send(
JSON.stringify({
type: 'host-hello',
v: 1,
relayHostId: this.options.relayHostId,
assignmentEpoch: this.options.assignmentEpoch,
hostPublicKeyB64: this.options.keypair.publicKeyB64,
appVersion: this.options.appVersion,
...(this.options.previousGeneration === undefined
? {}
: { previousGeneration: this.options.previousGeneration }),
...(this.options.controlResumeSecret
? { controlResumeSecret: this.options.controlResumeSecret }
: {})
encodeRelayHostHello({
...this.options,
hostPublicKeyB64: this.options.keypair.publicKeyB64
})
)
}
@@ -0,0 +1,92 @@
import { createHash } from 'node:crypto'
import { afterEach, describe, expect, it, vi } from 'vitest'
import nacl from 'tweetnacl'
import { WebSocketServer, type WebSocket } from 'ws'
import { RELAY_HOST_CLOSE_REASON } from '../../../shared/relay-host-close-reason'
import { RelayControlClient } from './relay-control-client'
type ObservedClose = { code: number; reason: string }
describe('RelayControlClient close reason', () => {
const servers: WebSocketServer[] = []
const clients: RelayControlClient[] = []
afterEach(async () => {
for (const client of clients.splice(0)) {
client.closeNow()
}
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
for (const socket of server.clients) {
socket.terminate()
}
server.close(() => resolve())
})
)
)
})
async function connectedClient(): Promise<{
client: RelayControlClient
closed: Promise<ObservedClose>
}> {
const server = new WebSocketServer({ host: '127.0.0.1', port: 0, perMessageDeflate: false })
servers.push(server)
await new Promise<void>((resolve) => server.once('listening', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('expected TCP relay test server')
}
const accepted = new Promise<WebSocket>((resolve) => server.once('connection', resolve))
const keypair = nacl.box.keyPair()
const client = new RelayControlClient({
cellUrl: `http://127.0.0.1:${address.port}`,
relayJwt: 'scoped-token',
relayHostId: createHash('sha256').update(keypair.publicKey).digest('base64url').slice(0, 16),
assignmentEpoch: 1,
identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' },
keypair: { ...keypair, publicKeyB64: Buffer.from(keypair.publicKey).toString('base64') },
appVersion: '1.2.3',
onConnectionOpen: vi.fn(),
onDrain: vi.fn(),
onClose: vi.fn()
})
clients.push(client)
// The handshake never completes here; only the transport close matters.
void client.connect().catch(() => {})
const socket = await accepted
// A pong proves the client socket left CONNECTING; closeNow can only send a
// close frame from OPEN, and that is the state a real sign-out fences from.
await new Promise<void>((resolve) => {
socket.once('pong', () => resolve())
socket.ping()
})
const closed = new Promise<ObservedClose>((resolve) => {
socket.once('close', (code, reason) => resolve({ code, reason: reason.toString() }))
})
return { client, closed }
}
it('delivers the sign-out reason to the cell', async () => {
const { client, closed } = await connectedClient()
client.closeNow(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
await expect(closed).resolves.toEqual({
code: 1000,
reason: RELAY_HOST_CLOSE_REASON.SIGNED_OUT
})
})
// Every non-auth close keeps today's abrupt terminate, so a cell can never
// read a quit, a rotation or a sleep as a sign-out.
it('closes abruptly with no reason when none is given', async () => {
const { client, closed } = await connectedClient()
client.closeNow()
await expect(closed).resolves.toEqual({ code: 1006, reason: '' })
})
})
@@ -8,6 +8,7 @@ import type {
RelayDrainMessage,
RelayHostHelloAckMessage
} from './relay-control-protocol'
import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason'
import type { RelayIdentity } from './relay-session-broker-contract'
import type { RelayAssignment } from './relay-http-client'
@@ -144,7 +145,7 @@ export class RelayControlOrigin {
}
}
async close(): Promise<void> {
async close(hostCloseReason?: RelayHostCloseReason): Promise<void> {
if (this.closed) {
return
}
@@ -154,7 +155,7 @@ export class RelayControlOrigin {
}
this.retiredControlTimers.clear()
for (const control of this.controls) {
control.closeNow()
control.closeNow(hostCloseReason)
}
this.controls.clear()
this.activeControl = null
@@ -166,8 +167,8 @@ export class RelayControlOrigin {
}
}
closeNow(): void {
void this.close()
closeNow(hostCloseReason?: RelayHostCloseReason): void {
void this.close(hostCloseReason)
}
private async openControl(overrides?: {
@@ -143,3 +143,29 @@ export function parseRelayControlMessage(raw: RawData): Record<string, unknown>
return null
}
}
export type RelayHostHello = {
relayHostId: string
assignmentEpoch: number
hostPublicKeyB64: string
appVersion: string
previousGeneration?: number
controlResumeSecret?: string
}
// Optional members are omitted rather than sent as undefined: the cell parses
// host-hello strictly and an explicit null is not the same as absent.
export function encodeRelayHostHello(hello: RelayHostHello): string {
return JSON.stringify({
type: 'host-hello',
v: 1,
relayHostId: hello.relayHostId,
assignmentEpoch: hello.assignmentEpoch,
hostPublicKeyB64: hello.hostPublicKeyB64,
appVersion: hello.appVersion,
...(hello.previousGeneration === undefined
? {}
: { previousGeneration: hello.previousGeneration }),
...(hello.controlResumeSecret ? { controlResumeSecret: hello.controlResumeSecret } : {})
})
}
@@ -0,0 +1,26 @@
import type WebSocket from 'ws'
import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason'
const NORMAL_CLOSE_CODE = 1000
const REASONED_CLOSE_FLUSH_MS = 1_000
// hostCloseReason: only auth loss names itself. Every other control close
// (rotation, drain, quit, sleep) stays an abrupt terminate, so the cell learns
// nothing and can never read a restart as a sign-out. A named close has to
// reach the cell as a real close frame, but the fence must still be hard —
// bound the handshake and then terminate.
export function closeRelayControlSocket(
socket: WebSocket | null,
hostCloseReason?: RelayHostCloseReason
): void {
if (!socket) {
return
}
if (hostCloseReason && socket.readyState === socket.OPEN) {
socket.close(NORMAL_CLOSE_CODE, hostCloseReason)
const timer = setTimeout(() => socket.terminate(), REASONED_CLOSE_FLUSH_MS)
timer.unref?.()
return
}
socket.terminate()
}
+3 -2
View File
@@ -4,6 +4,7 @@ import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring'
import { RelayControlOrigin } from './relay-control-origin'
import type { RelayControlClient } from './relay-control-client'
import type { RelayDrainMessage } from './relay-control-protocol'
import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason'
import { RelayDrainRetrySchedule } from './relay-drain-retry-schedule'
import { RelayHttpError, requestRelayAssignment, type RelayAssignment } from './relay-http-client'
import type { RelayBrokerStatus, RelayIdentity } from './relay-session-broker-contract'
@@ -79,7 +80,7 @@ export class RelayOriginPool {
}
}
closeNow(): void {
closeNow(hostCloseReason?: RelayHostCloseReason): void {
if (this.closed) {
return
}
@@ -94,7 +95,7 @@ export class RelayOriginPool {
}
this.drainTimers.clear()
for (const origin of this.origins) {
origin.closeNow()
origin.closeNow(hostCloseReason)
}
this.origins.clear()
this.drainingOrigins.clear()
@@ -6,6 +6,7 @@ import type {
MobileRelayEndpoint,
PairingProvisionRelayParams
} from '../../../shared/mobile-relay-credential-contract'
import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reason'
import type { DeviceCredentialInstallAuthorization } from './relay-control-requests'
import {
deriveRelayHostId,
@@ -180,7 +181,7 @@ export class RelaySessionBroker {
return result
}
closeNow(): void {
closeNow(hostCloseReason?: RelayHostCloseReason): void {
if (this.closed) {
return
}
@@ -190,7 +191,7 @@ export class RelaySessionBroker {
clearTimeout(this.refreshTimer)
this.refreshTimer = null
}
this.originPool.closeNow()
this.originPool.closeNow(hostCloseReason)
if (publishOffline) {
this.options.onStatus('offline')
}
@@ -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 { RELAY_HOST_CLOSE_REASON } from '../../shared/relay-host-close-reason'
export function attachMainWindowCoreServices(
window: BrowserWindow,
@@ -90,7 +91,10 @@ export function attachMainWindowCoreServices(
})
},
onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(),
onBeforeOrcaProfileSignOut: () => state.desktopRelayService?.fenceAndCloseNow()
// 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: () =>
state.desktopRelayService?.fenceAndCloseNow(RELAY_HOST_CLOSE_REASON.SIGNED_OUT)
},
state.pluginService ?? undefined,
state.pluginMarketplaceService && state.pluginMarketplaceInstaller
+19
View File
@@ -0,0 +1,19 @@
// Why a WebSocket close reason and not a JSON field: every relay-hello and
// director-resolve schema on the phone is zod `.strict()`, so an added key is a
// hard parse failure on already-shipped phones. The close reason is a wire slot
// old peers never read, which makes it the only additive channel here.
export const RELAY_HOST_CLOSE_REASON = {
// The desktop lost its Orca Cloud session (cleared, or refused with 401).
SIGNED_OUT: 'signed-out'
} as const
export type RelayHostCloseReason =
(typeof RELAY_HOST_CLOSE_REASON)[keyof typeof RELAY_HOST_CLOSE_REASON]
const REASONS: readonly string[] = Object.values(RELAY_HOST_CLOSE_REASON)
// Close reasons are attacker-adjacent free text; only exact known members count.
export function relayHostCloseReasonFrom(value: unknown): RelayHostCloseReason | null {
const text = typeof value === 'string' ? value : (value?.toString() ?? '')
return REASONS.includes(text) ? (text as RelayHostCloseReason) : null
}