mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user