mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(relay): wait through a scheduled reconnect and name why Relay is not ready (#27141)
* fix(relay): wait through a scheduled reconnect and name why Relay is not ready * fix(relay): add the new mint-failure notice strings to the English catalog --------- Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
@@ -10,6 +10,7 @@ import type { RelayHostCloseReason } from '../../../shared/relay-host-close-reas
|
||||
import { readRelayAuthContext } from './relay-auth-context'
|
||||
import { relayIdentityKey } from './relay-auth-identity'
|
||||
import { RelayAuthCoordinator } from './relay-auth-coordinator'
|
||||
import { RelayUnavailableError } from './relay-readiness'
|
||||
import { RelaySessionBroker, type RelayBrokerStatus } from './relay-session-broker'
|
||||
import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer'
|
||||
import type {
|
||||
@@ -34,6 +35,10 @@ type DesktopRelayServiceOptions = {
|
||||
// transient auth read) must not stay dead until the user clicks Retry. The
|
||||
// cadence is slow because it is a safety net, not the primary retry path.
|
||||
const RELAY_LIVENESS_INTERVAL_MS = 5 * 60_000
|
||||
// How long a demand caller waits through a scheduled reconnect. Phone requests
|
||||
// get less than a desktop mint so the reply lands inside the phone's budget.
|
||||
const PAIRING_MINT_READINESS_WAIT_MS = 20_000
|
||||
const PHONE_REQUEST_READINESS_WAIT_MS = 10_000
|
||||
|
||||
export class DesktopRelayService {
|
||||
private readonly coordinator: RelayAuthCoordinator
|
||||
@@ -113,7 +118,7 @@ export class DesktopRelayService {
|
||||
relayDeviceId: string
|
||||
): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> {
|
||||
return await this.withTransientDemand(`pairing:${relayDeviceId}`, async () => {
|
||||
const broker = await this.requireActiveBroker()
|
||||
const broker = await this.requireActiveBroker(PAIRING_MINT_READINESS_WAIT_MS)
|
||||
const relay = await broker.createPairingRelay(relayDeviceId)
|
||||
return {
|
||||
relay,
|
||||
@@ -151,7 +156,9 @@ export class DesktopRelayService {
|
||||
return { v: 1, relay: null }
|
||||
}
|
||||
return await this.withTransientDemand(`endpoints:${context.deviceId}`, async () => {
|
||||
const broker = await this.activeBrokerForDemand()
|
||||
const broker = await this.requireActiveBroker(PHONE_REQUEST_READINESS_WAIT_MS).catch(
|
||||
() => null
|
||||
)
|
||||
if (!broker?.endpoint) {
|
||||
return { v: 1, relay: null }
|
||||
}
|
||||
@@ -191,7 +198,7 @@ export class DesktopRelayService {
|
||||
throw new Error('relay_disabled_for_device')
|
||||
}
|
||||
return await this.withTransientDemand(`provision:${context.deviceId}`, async () => {
|
||||
const broker = await this.requireActiveBroker()
|
||||
const broker = await this.requireActiveBroker(PHONE_REQUEST_READINESS_WAIT_MS)
|
||||
if (!broker.endpoint) {
|
||||
throw new Error('relay_control_not_active')
|
||||
}
|
||||
@@ -282,17 +289,14 @@ export class DesktopRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
private async activeBrokerForDemand(): Promise<RelaySessionBroker | null> {
|
||||
const broker = this.coordinator.getLiveBroker() ?? (await this.coordinator.waitForLiveBroker())
|
||||
return broker instanceof RelaySessionBroker ? broker : null
|
||||
}
|
||||
|
||||
private async requireActiveBroker(): Promise<RelaySessionBroker> {
|
||||
const broker = await this.activeBrokerForDemand()
|
||||
if (!broker) {
|
||||
throw new Error('relay_control_not_active')
|
||||
private async requireActiveBroker(waitMs: number): Promise<RelaySessionBroker> {
|
||||
const readiness = await this.coordinator.waitForReadiness(waitMs)
|
||||
if (readiness.ready && readiness.broker instanceof RelaySessionBroker) {
|
||||
return readiness.broker
|
||||
}
|
||||
return broker
|
||||
throw new RelayUnavailableError(
|
||||
readiness.ready ? { reason: 'control_not_active', retryAt: null } : readiness
|
||||
)
|
||||
}
|
||||
|
||||
private refreshDemand(): void {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mobileRelayMintFailureFromUnknown } from '../../../shared/mobile-relay-mint-failure'
|
||||
import { DesktopRelayService } from './desktop-relay-service'
|
||||
import { RelayAuthCoordinator } from './relay-auth-coordinator'
|
||||
import type { RelayAuthContext } from './relay-auth-identity'
|
||||
import { RelayHttpError } from './relay-http-client'
|
||||
import { relayUnavailableReasonFor } from './relay-readiness'
|
||||
import { RelaySessionBroker } from './relay-session-broker'
|
||||
|
||||
const context: RelayAuthContext = {
|
||||
identity: { userId: 'user-1', profileId: 'profile-1', organizationId: 'org-1' },
|
||||
accessToken: 'access-1',
|
||||
relayEntitled: true
|
||||
}
|
||||
|
||||
function fakeSessionBroker(): RelaySessionBroker {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the service only calls the stubbed members below.
|
||||
const broker = Object.create(RelaySessionBroker.prototype) as RelaySessionBroker
|
||||
Object.defineProperties(broker, {
|
||||
closeNow: { value: vi.fn() },
|
||||
isLive: { value: () => true },
|
||||
createPairingRelay: { value: vi.fn(async () => ({ inviteExpiresAt: 1 })) },
|
||||
endpoint: { value: null },
|
||||
hostId: { value: 'host-1' },
|
||||
ownerIdentityKey: { value: 'owner-1' }
|
||||
})
|
||||
return broker
|
||||
}
|
||||
|
||||
function serviceWith(coordinator: RelayAuthCoordinator): DesktopRelayService {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: createPairingRelay touches only the fields assigned here.
|
||||
const service = Object.create(DesktopRelayService.prototype) as DesktopRelayService
|
||||
return Object.assign(service, {
|
||||
coordinator,
|
||||
stopped: false,
|
||||
livenessTimer: null,
|
||||
demandExpiryTimer: null,
|
||||
demandLedger: { acquireTransient: () => () => {}, nextPendingExpiry: () => null }
|
||||
})
|
||||
}
|
||||
|
||||
async function mintFailureCode(service: DesktopRelayService): Promise<string> {
|
||||
const error = await service.createPairingRelay('device-1').then(
|
||||
() => null,
|
||||
(caught: unknown) => caught
|
||||
)
|
||||
return mobileRelayMintFailureFromUnknown({
|
||||
stage: 'create_pairing_relay',
|
||||
error,
|
||||
fallbackCode: 'relay_mint_failed',
|
||||
fallbackMessage: 'failed'
|
||||
}).code
|
||||
}
|
||||
|
||||
describe('relay readiness', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('waits through the retry a cold-start failure scheduled instead of failing the mint', async () => {
|
||||
vi.useFakeTimers()
|
||||
const broker = fakeSessionBroker()
|
||||
const openBroker = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new RelayHttpError('assignment', 503))
|
||||
.mockResolvedValueOnce(broker)
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => context,
|
||||
openBroker,
|
||||
onStatus: () => {},
|
||||
random: () => 1
|
||||
})
|
||||
const service = serviceWith(coordinator)
|
||||
const mint = service.createPairingRelay('device-1')
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
await expect(mint).resolves.toMatchObject({ binding: { relayHostId: 'host-1' } })
|
||||
expect(openBroker).toHaveBeenCalledTimes(2)
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('reports the failure cause when the retry falls outside the wait', async () => {
|
||||
vi.useFakeTimers()
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => context,
|
||||
openBroker: async () => {
|
||||
throw new RelayHttpError('assignment', 503, 60_000)
|
||||
},
|
||||
onStatus: () => {}
|
||||
})
|
||||
const service = serviceWith(coordinator)
|
||||
await expect(mintFailureCode(service)).resolves.toBe('relay_director_assignment_503')
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('names a signed-out or unentitled desktop instead of relay_control_not_active', async () => {
|
||||
let current: RelayAuthContext | null = null
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => current,
|
||||
openBroker: async () => fakeSessionBroker(),
|
||||
onStatus: () => {}
|
||||
})
|
||||
const service = serviceWith(coordinator)
|
||||
await expect(mintFailureCode(service)).resolves.toBe('relay_signed_out')
|
||||
current = { ...context, relayEntitled: false }
|
||||
await expect(mintFailureCode(service)).resolves.toBe('relay_not_entitled')
|
||||
service.stop()
|
||||
})
|
||||
|
||||
it('stops waiting when a fence cancels the scheduled retry', async () => {
|
||||
vi.useFakeTimers()
|
||||
const coordinator = new RelayAuthCoordinator({
|
||||
readContext: async () => context,
|
||||
openBroker: async () => {
|
||||
throw new RelayHttpError('token-exchange', 502)
|
||||
},
|
||||
onStatus: () => {},
|
||||
random: () => 1
|
||||
})
|
||||
coordinator.reconcile()
|
||||
const waiting = coordinator.waitForReadiness(20_000)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
coordinator.fenceAndCloseNow()
|
||||
await expect(waiting).resolves.toEqual({
|
||||
ready: false,
|
||||
reason: 'control_not_active',
|
||||
retryAt: null
|
||||
})
|
||||
})
|
||||
|
||||
function coldStartWithHungRetry(): RelayAuthCoordinator {
|
||||
const openBroker = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new RelayHttpError('assignment', 503))
|
||||
.mockImplementationOnce(() => new Promise(() => {}))
|
||||
return new RelayAuthCoordinator({
|
||||
readContext: async () => context,
|
||||
openBroker,
|
||||
onStatus: () => {},
|
||||
random: () => 0.5
|
||||
})
|
||||
}
|
||||
|
||||
it('returns at its deadline while the retry it followed is still opening', async () => {
|
||||
vi.useFakeTimers()
|
||||
const coordinator = coldStartWithHungRetry()
|
||||
coordinator.reconcile()
|
||||
let settled: unknown = null
|
||||
void coordinator.waitForReadiness(10_000).then((readiness) => (settled = readiness))
|
||||
await vi.advanceTimersByTimeAsync(9_999)
|
||||
expect(settled).toBeNull()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(settled).toMatchObject({ ready: false, reason: 'director_assignment_503' })
|
||||
coordinator.stop()
|
||||
})
|
||||
|
||||
it.each(['fence', 'stop'] as const)(
|
||||
'a %s releases a waiter whose followed retry is still opening',
|
||||
async (action) => {
|
||||
vi.useFakeTimers()
|
||||
const coordinator = coldStartWithHungRetry()
|
||||
coordinator.reconcile()
|
||||
let settled: unknown = null
|
||||
void coordinator.waitForReadiness(20_000).then((readiness) => (settled = readiness))
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(settled).toBeNull()
|
||||
if (action === 'fence') {
|
||||
coordinator.fenceAndCloseNow()
|
||||
} else {
|
||||
coordinator.stop()
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(settled).toMatchObject({ ready: false, retryAt: null })
|
||||
coordinator.stop()
|
||||
}
|
||||
)
|
||||
|
||||
it('classifies transport failures into actionable reasons', () => {
|
||||
const tls = new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('unable to get local issuer certificate'), {
|
||||
code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY'
|
||||
})
|
||||
})
|
||||
expect(relayUnavailableReasonFor(tls)).toBe('tls_untrusted')
|
||||
expect(relayUnavailableReasonFor(new RelayHttpError('assignment', 429))).toBe('rate_limited')
|
||||
expect(relayUnavailableReasonFor(new Error('relay_control_closed_4429'))).toBe('rate_limited')
|
||||
expect(relayUnavailableReasonFor(new Error('relay_control_connect_timeout'))).toBe(
|
||||
'control_connect_timeout'
|
||||
)
|
||||
expect(relayUnavailableReasonFor(new Error('ECONNRESET with secret=abc'))).toBe('network')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,11 @@ import { relayStatusCellUrl } from '../../../shared/mobile-relay-status'
|
||||
import type { RelayBrokerStatus } from './relay-session-broker'
|
||||
import type { RelayAccessTokenRefresh } from './relay-session-broker-contract'
|
||||
import { RelayHttpError, shouldRetryRelayConnectionError } from './relay-http-client'
|
||||
import {
|
||||
RelayReadinessWaiters,
|
||||
relayUnavailableReasonFor,
|
||||
type RelayUnavailable
|
||||
} from './relay-readiness'
|
||||
import { RelayRetrySchedule } from './relay-retry-schedule'
|
||||
import { RelayLingerTimer } from './relay-linger-timer'
|
||||
import { relayIdentityKey, type RelayAuthContext } from './relay-auth-identity'
|
||||
@@ -29,6 +34,10 @@ type RelayAuthCoordinatorOptions = {
|
||||
random?: () => number
|
||||
}
|
||||
|
||||
export type RelayReadiness =
|
||||
| { ready: true; broker: CoordinatedRelayBroker }
|
||||
| ({ ready: false } & RelayUnavailable)
|
||||
|
||||
type BrokerOwnership = {
|
||||
identityKey: string
|
||||
broker: CoordinatedRelayBroker | null
|
||||
@@ -51,6 +60,9 @@ export class RelayAuthCoordinator {
|
||||
private latestReconcile: Promise<void> = Promise.resolve()
|
||||
private readonly linger = new RelayLingerTimer()
|
||||
private readonly retry: RelayRetrySchedule
|
||||
// Why the latest reconcile left no live broker; cleared when one registers.
|
||||
private unavailableReason: RelayUnavailable['reason'] | null = null
|
||||
private readonly readinessWaiters = new RelayReadinessWaiters()
|
||||
private stopped = false
|
||||
|
||||
constructor(options: RelayAuthCoordinatorOptions) {
|
||||
@@ -87,6 +99,9 @@ export class RelayAuthCoordinator {
|
||||
this.retry.reset()
|
||||
this.invalidatePendingOwnerships()
|
||||
this.invalidateOwnership(hostCloseReason)
|
||||
this.unavailableReason =
|
||||
hostCloseReason === RELAY_HOST_CLOSE_REASON.SIGNED_OUT ? 'signed_out' : null
|
||||
this.readinessWaiters.releaseAll()
|
||||
this.publish('offline')
|
||||
}
|
||||
|
||||
@@ -128,19 +143,45 @@ export class RelayAuthCoordinator {
|
||||
this.beginReconcile(false)
|
||||
}
|
||||
|
||||
readiness(): RelayReadiness {
|
||||
const broker = this.getLiveBroker()
|
||||
const reason = this.unavailableReason ?? 'control_not_active'
|
||||
return broker ? { ready: true, broker } : { ready: false, reason, retryAt: this.retry.retryAt }
|
||||
}
|
||||
|
||||
// Awaits the reconcile in flight (and any it was superseded by), never a retry.
|
||||
async waitForLiveBroker(): Promise<CoordinatedRelayBroker | null> {
|
||||
while (!this.stopped) {
|
||||
const broker = this.getLiveBroker()
|
||||
if (broker) {
|
||||
return broker
|
||||
}
|
||||
const pending = this.latestReconcile
|
||||
let pending: Promise<void> | null = null
|
||||
while (!this.stopped && !this.getLiveBroker() && pending !== this.latestReconcile) {
|
||||
pending = this.latestReconcile
|
||||
await pending
|
||||
if (pending === this.latestReconcile) {
|
||||
return this.getLiveBroker()
|
||||
}
|
||||
return this.stopped ? null : this.getLiveBroker()
|
||||
}
|
||||
|
||||
// Why waitMs: a cold start that fails once arms a retry seconds away; a caller
|
||||
// with time to spare waits for it instead of reporting the first failure. The
|
||||
// deadline bounds every wait, and a fence releases the caller at once.
|
||||
async waitForReadiness(waitMs: number): Promise<RelayReadiness> {
|
||||
const deadline = Date.now() + waitMs
|
||||
while (!this.stopped && !this.getLiveBroker()) {
|
||||
const pending = this.latestReconcile
|
||||
if (!(await this.readinessWaiters.until(pending, deadline))) {
|
||||
break
|
||||
}
|
||||
if (pending !== this.latestReconcile) {
|
||||
continue
|
||||
}
|
||||
const retryAt = this.retry.retryAt
|
||||
if (this.getLiveBroker() || retryAt === null || retryAt > deadline) {
|
||||
break
|
||||
}
|
||||
const retried = await this.readinessWaiters.until(this.retry.settled(), deadline)
|
||||
if (!retried || pending === this.latestReconcile) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return null
|
||||
return this.readiness()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -159,12 +200,14 @@ export class RelayAuthCoordinator {
|
||||
this.linger.cancel()
|
||||
this.retry.reset()
|
||||
this.invalidateOwnership(authLossCloseReason(context))
|
||||
this.unavailableReason = context ? 'not_entitled' : 'signed_out'
|
||||
this.publish('offline')
|
||||
return
|
||||
}
|
||||
const nextIdentityKey = relayIdentityKey(context.identity)
|
||||
if (expectedIdentityKey && nextIdentityKey !== expectedIdentityKey) {
|
||||
this.retry.reset()
|
||||
this.unavailableReason = 'identity_changed'
|
||||
this.publish('offline')
|
||||
return
|
||||
}
|
||||
@@ -188,6 +231,7 @@ export class RelayAuthCoordinator {
|
||||
(this.ownership.broker?.isLive?.() ?? true)
|
||||
) {
|
||||
this.retry.reset()
|
||||
this.unavailableReason = null
|
||||
this.publish('registered')
|
||||
return
|
||||
}
|
||||
@@ -221,6 +265,7 @@ export class RelayAuthCoordinator {
|
||||
}
|
||||
this.ownership = ownership
|
||||
this.retry.reset()
|
||||
this.unavailableReason = null
|
||||
this.publish('registered')
|
||||
} catch (error) {
|
||||
if (this.isEpochCurrent(epoch)) {
|
||||
@@ -230,6 +275,7 @@ export class RelayAuthCoordinator {
|
||||
'[relay] broker reconcile failed:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
this.unavailableReason = relayUnavailableReasonFor(error)
|
||||
this.publish('offline')
|
||||
if (shouldRetryRelayConnectionError(error)) {
|
||||
const retryAfterMs = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { RelayHttpError } from './relay-http-client'
|
||||
|
||||
// Why the last reconcile could not produce a live broker. Every value is a
|
||||
// lowercase token so `relay_<reason>` stays a valid mint failure code.
|
||||
export type RelayUnavailableReason =
|
||||
| 'signed_out'
|
||||
| 'not_entitled'
|
||||
| 'identity_changed'
|
||||
| 'rate_limited'
|
||||
| 'tls_untrusted'
|
||||
| 'connect_timeout'
|
||||
| 'network'
|
||||
| 'control_not_active'
|
||||
| `director_${'token_exchange' | 'assignment'}_${number}`
|
||||
// A relay-coded error message with its `relay_` prefix removed.
|
||||
| `control_${string}`
|
||||
|
||||
export type RelayUnavailable = { reason: RelayUnavailableReason; retryAt: number | null }
|
||||
|
||||
const TLS_TRUST_ERROR_CODES = new Set([
|
||||
'UNABLE_TO_GET_ISSUER_CERT',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'CERT_UNTRUSTED',
|
||||
'CERT_SIGNATURE_FAILURE'
|
||||
])
|
||||
const TIMEOUT_ERROR_CODES = new Set(['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT'])
|
||||
const RELAY_CONTROL_MESSAGE = /^relay_(control_[a-z0-9_]{1,60})$/
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
return typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string'
|
||||
? error.code
|
||||
: null
|
||||
}
|
||||
|
||||
// undici wraps the socket error as `TypeError('fetch failed', { cause })`.
|
||||
function errorChain(error: unknown): unknown[] {
|
||||
const chain: unknown[] = []
|
||||
let current = error
|
||||
while (current !== undefined && current !== null && chain.length < 4) {
|
||||
chain.push(current)
|
||||
current = current instanceof Error ? current.cause : undefined
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
export function relayUnavailableReasonFor(error: unknown): RelayUnavailableReason {
|
||||
if (error instanceof RelayHttpError) {
|
||||
return error.statusCode === 429
|
||||
? 'rate_limited'
|
||||
: `director_${error.operation === 'token-exchange' ? 'token_exchange' : 'assignment'}_${error.statusCode}`
|
||||
}
|
||||
for (const link of errorChain(error)) {
|
||||
const code = errorCode(link)
|
||||
if (code && (TLS_TRUST_ERROR_CODES.has(code) || code.startsWith('CERT_'))) {
|
||||
return 'tls_untrusted'
|
||||
}
|
||||
if (code && TIMEOUT_ERROR_CODES.has(code)) {
|
||||
return 'connect_timeout'
|
||||
}
|
||||
}
|
||||
const message = error instanceof Error ? RELAY_CONTROL_MESSAGE.exec(error.message) : null
|
||||
if (message?.[1] === 'control_closed_4429') {
|
||||
return 'rate_limited'
|
||||
}
|
||||
return message ? `control_${message[1].slice('control_'.length)}` : 'network'
|
||||
}
|
||||
|
||||
// Bounds a readiness wait by its caller's deadline and lets a fence release it,
|
||||
// without cancelling the shared reconnect work other callers still need.
|
||||
export class RelayReadinessWaiters {
|
||||
private readonly releases = new Set<() => void>()
|
||||
|
||||
// Resolves true when `work` settles first, false at the deadline or on release.
|
||||
async until(work: Promise<unknown>, deadline: number): Promise<boolean> {
|
||||
let release = (): void => {}
|
||||
const released = new Promise<false>((resolve) => {
|
||||
release = () => resolve(false)
|
||||
})
|
||||
const timer = setTimeout(release, Math.max(0, deadline - Date.now()))
|
||||
this.releases.add(release)
|
||||
try {
|
||||
return await Promise.race([work.then(() => true), released])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
this.releases.delete(release)
|
||||
}
|
||||
}
|
||||
|
||||
releaseAll(): void {
|
||||
for (const release of this.releases) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Carries the readiness reason as a `relay_*` code that mint failure reporting forwards as-is.
|
||||
export class RelayUnavailableError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(readonly unavailable: RelayUnavailable) {
|
||||
super(`relay_${unavailable.reason}`)
|
||||
this.code = this.message
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ const RETRY_MAX_MS = 5 * 60_000
|
||||
export class RelayRetrySchedule {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private attempt = 0
|
||||
private dueAt: number | null = null
|
||||
private readonly settledWaiters = new Set<() => void>()
|
||||
|
||||
constructor(private readonly random: () => number = Math.random) {}
|
||||
|
||||
@@ -13,6 +15,17 @@ export class RelayRetrySchedule {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
get retryAt(): number | null {
|
||||
return this.dueAt
|
||||
}
|
||||
|
||||
// Resolves once the pending retry has run or was cancelled.
|
||||
settled(): Promise<void> {
|
||||
return this.timer
|
||||
? new Promise((resolve) => this.settledWaiters.add(resolve))
|
||||
: Promise.resolve()
|
||||
}
|
||||
|
||||
schedule(retryAfterMs: number, retry: () => void): void {
|
||||
if (this.timer) {
|
||||
return
|
||||
@@ -20,14 +33,13 @@ export class RelayRetrySchedule {
|
||||
const exponent = Math.min(this.attempt, Math.ceil(Math.log2(RETRY_MAX_MS / RETRY_BASE_MS)))
|
||||
const capMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** exponent)
|
||||
this.attempt++
|
||||
const jitterMs = Math.floor(this.random() * (capMs + 1))
|
||||
this.timer = setTimeout(
|
||||
() => {
|
||||
this.timer = null
|
||||
retry()
|
||||
},
|
||||
Math.max(jitterMs, retryAfterMs)
|
||||
)
|
||||
const delayMs = Math.max(Math.floor(this.random() * (capMs + 1)), retryAfterMs)
|
||||
this.dueAt = Date.now() + delayMs
|
||||
this.timer = setTimeout(() => {
|
||||
this.clearTimer()
|
||||
retry()
|
||||
this.settle()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
@@ -36,9 +48,25 @@ export class RelayRetrySchedule {
|
||||
|
||||
// Keeps the attempt count: a superseding reconcile still backs off from it.
|
||||
cancel(): void {
|
||||
if (this.timer) {
|
||||
this.clearTimer()
|
||||
this.settle()
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.timer = null
|
||||
this.dueAt = null
|
||||
}
|
||||
|
||||
private settle(): void {
|
||||
const waiters = [...this.settledWaiters]
|
||||
this.settledWaiters.clear()
|
||||
for (const resolve of waiters) {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,28 @@ describe('HeroFlow height', () => {
|
||||
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('names a signed-out or unentitled desktop instead of offering a futile retry', () => {
|
||||
renderFlow(1, {
|
||||
relayMintFailure: {
|
||||
code: 'relay_signed_out',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'Relay pairing invite request failed'
|
||||
}
|
||||
})
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Sign in again to use Orca Relay')
|
||||
expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull()
|
||||
cleanup()
|
||||
renderFlow(1, {
|
||||
relayMintFailure: {
|
||||
code: 'relay_not_entitled',
|
||||
stage: 'create_pairing_relay',
|
||||
message: 'Relay pairing invite request failed'
|
||||
}
|
||||
})
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('isn’t included with this Orca account')
|
||||
expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the mint-failure notice when a Relay QR is shown', () => {
|
||||
renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' })
|
||||
expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument()
|
||||
|
||||
@@ -25,9 +25,10 @@ export function MobileRelayMintFailureNotice({
|
||||
}): React.JSX.Element {
|
||||
const providerMissing = failure.stage === 'provider_missing'
|
||||
// Why: a revoked cloud session fails every mint; "retry or use LAN" hides the one action that works.
|
||||
const reconnectRequired = useAppStore(
|
||||
(state) => state.orcaProfileAuthStatus?.state === 'reconnect-required'
|
||||
)
|
||||
const reconnectRequired =
|
||||
useAppStore((state) => state.orcaProfileAuthStatus?.state === 'reconnect-required') ||
|
||||
failure.code === 'relay_signed_out'
|
||||
const notEntitled = failure.code === 'relay_not_entitled'
|
||||
const [showBusyFeedback, setShowBusyFeedback] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!busy) {
|
||||
@@ -53,10 +54,15 @@ export function MobileRelayMintFailureNotice({
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.reconnectTitle',
|
||||
'Your Orca account session expired.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.title',
|
||||
'Couldn’t create a Relay pairing code.'
|
||||
)
|
||||
: notEntitled
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.notEntitledTitle',
|
||||
'Orca Relay isn’t included with this Orca account.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.title',
|
||||
'Couldn’t create a Relay pairing code.'
|
||||
)
|
||||
const body = visibleBusy
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody',
|
||||
@@ -72,10 +78,20 @@ export function MobileRelayMintFailureNotice({
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.reconnectBody',
|
||||
'Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.body',
|
||||
'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: notEntitled
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody',
|
||||
'Use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: failure.code === 'relay_rate_limited'
|
||||
? translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.rateLimitedBody',
|
||||
'Orca Relay is busy. Wait a minute and retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.mobile.MobileRelayMintFailureNotice.body',
|
||||
'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -105,7 +121,7 @@ export function MobileRelayMintFailureNotice({
|
||||
<Button type="button" size={compact ? 'xs' : 'sm'} onClick={onUseLan}>
|
||||
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')}
|
||||
</Button>
|
||||
{!providerMissing && !reconnectRequired ? (
|
||||
{!providerMissing && !reconnectRequired && !notEntitled ? (
|
||||
<Button
|
||||
type="button"
|
||||
size={compact ? 'xs' : 'sm'}
|
||||
|
||||
@@ -14741,7 +14741,9 @@
|
||||
"retry": "Retry Relay",
|
||||
"copyDiagnostics": "Copy diagnostics",
|
||||
"reconnectTitle": "Your Orca account session expired.",
|
||||
"reconnectBody": "Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi."
|
||||
"reconnectBody": "Sign in again to use Orca Relay, or use LAN to pair over Tailscale or the same Wi‑Fi.",
|
||||
"notEntitledTitle": "Orca Relay isn’t included with this Orca account.",
|
||||
"rateLimitedBody": "Orca Relay is busy. Wait a minute and retry, or use LAN to pair over Tailscale or the same Wi‑Fi."
|
||||
}
|
||||
},
|
||||
"gitlab": {
|
||||
|
||||
Reference in New Issue
Block a user