From 889c739c12fa009ad18953f1a0274007ce42fdd8 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:05:52 -0400 Subject: [PATCH] fix(mobile): stop unrenewed-grace rotation churn and gate cadence gaps - skip proactive rotation when the resume confirmation reports renewed=false (a re-resume provably returns the same unchanged deadline; rotating churned one session replacement per clamp floor, ~60/hour, until a fresh credential) - armCredentialReprobe under a held gate mints the tick's pass token so the effective reprobe cadence stays 60s..15min instead of doubling to ~30min - registerFailure honors scheduleRetry=false in gate branches: no reprobe timer is armed while backgrounded/stopped; foreground resume re-arms - extract RelayRetryDelays and supervisor test fakes into their own modules (max-lines) --- .../mobile-endpoint-supervisor-test-fakes.ts | 169 +++++++++++++++ .../mobile-endpoint-supervisor.test.ts | 199 +++--------------- .../transport/mobile-endpoint-supervisor.ts | 5 +- .../mobile-relay-reconnect-controller.test.ts | 42 ++++ .../mobile-relay-reconnect-controller.ts | 76 +++---- .../transport/mobile-relay-retry-delays.ts | 45 ++++ 6 files changed, 319 insertions(+), 217 deletions(-) create mode 100644 mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts create mode 100644 mobile/src/transport/mobile-relay-retry-delays.ts diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts new file mode 100644 index 000000000000..d483c6536e8a --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -0,0 +1,169 @@ +import { vi } from 'vitest' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor' +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile, RpcResponse } from './types' + +export class FakeSession implements RpcClient { + readonly sendRequest = vi.fn( + async (_method: string, _params?: unknown): Promise => ({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + + constructor(private state: ConnectionState) {} + + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publishState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +export class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + constructor( + state: ConnectionState, + private readonly failure: Error | null = null, + private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000, + private readonly renewed = true + ) { + super(state) + } + // Why: production-realistic defaults — fictional fake values hid three + // live defects in this subsystem (latch, churn, int32 timer overflow). + getAttachDeadlineAt = () => Date.now() + 10_000 + getResumeExpiresAt = () => this.resumeExpiry + getResumeConfirmation = () => ({ + v: 1 as const, + reqId: 'confirm-1', + currentVersion: 2, + acceptedAs: this.renewed ? ('current' as const) : ('grace' as const), + renewed: this.renewed, + resumeExpiresAt: this.resumeExpiry + }) + getFailure = () => this.failure +} + +export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { + private path: MobileConnectionPath + private generation = 1 + + constructor(state: ConnectionState, path: MobileConnectionPath) { + super(state) + this.path = path + } + + migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { + if (session.getState() !== 'connected') { + session.close() + throw new Error(`replacement session ${session.getState()}`) + } + this.path = path + this.generation += 1 + this.publishState('connected') + }) + suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getActivePath = () => this.path + getGeneration = () => this.generation +} + +export const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} +export const host: HostProfile = { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } + ], + relayHostId: relay.relayHostId, + relay +} +export const bundle: MobileRelayCredentialBundle = { + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 2, + expiresAt: Number.MAX_SAFE_INTEGER + } +} + +export function dependencies( + overrides: Partial = {} +): MobileEndpointSupervisorDependencies { + return { + openDirect: vi.fn(() => new FakeSession('connected')), + openRelay: vi.fn(() => new FakeRelaySession('connected')), + resolveRelay: vi.fn(async ({ relay }) => relay), + readBundle: vi.fn(async () => bundle), + writeBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + now: Date.now, + randomBytes: (length) => new Uint8Array(length).fill(1), + setTimer: setTimeout, + clearTimer: clearTimeout, + ...overrides + } +} + +export function mockCredentialRotation(logical: FakeLogicalClient): void { + let installResult: Record | null = null + logical.sendRequest.mockImplementation(async (method, params) => { + const request = params as { installReqId?: string; reqId?: string } + if (method === 'pairing.provisionRelay') { + installResult = { + v: 1, + reqId: request.reqId, + authorizationMode: 'authenticated-direct', + currentVersion: 3, + resumeExpiresAt: Date.now() + 300_000, + graceExpiresAt: Date.now() + 60_000 + } + return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } } + } + return { + id: 'rpc-1', + ok: true, + result: { + v: 1, + relay, + installStatus: installResult + ? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult } + : { v: 1, reqId: request.installReqId, state: 'not-found' } + }, + _meta: { runtimeId: 'runtime-1' } + } + }) +} diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index e6d0ff8bb323..d1b8b6918cd9 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -2,180 +2,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' import { RelayOuterError } from './mobile-relay-e2ee-link' -import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { - MobileEndpointSupervisor, - type MobileEndpointSupervisorDependencies -} from './mobile-endpoint-supervisor' -import type { RpcClient } from './rpc-client' -import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' -import type { ConnectionState, HostProfile, RpcResponse } from './types' + bundle, + dependencies, + FakeLogicalClient, + FakeRelaySession, + FakeSession, + host, + mockCredentialRotation, + relay +} 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) })) -class FakeSession implements RpcClient { - readonly sendRequest = vi.fn( - async (_method: string, _params?: unknown): Promise => ({ - id: 'rpc-1', - ok: true, - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - ) - readonly subscribe = vi.fn(() => () => {}) - readonly updateTerminalSubscriptionViewport = vi.fn() - readonly notifyForeground = vi.fn() - readonly close = vi.fn() - private readonly listeners = new Set<(state: ConnectionState) => void>() - - constructor(private state: ConnectionState) {} - - getState = () => this.state - getReconnectAttempt = () => 0 - getLastConnectedAt = () => null - onStateChange = (listener: (state: ConnectionState) => void) => { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - publishState(state: ConnectionState): void { - this.state = state - for (const listener of this.listeners) { - listener(state) - } - } -} - -class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { - constructor( - state: ConnectionState, - private readonly failure: Error | null = null, - private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000 - ) { - super(state) - } - // Why: production-realistic defaults — fictional fake values hid three - // live defects in this subsystem (latch, churn, int32 timer overflow). - getAttachDeadlineAt = () => Date.now() + 10_000 - getResumeExpiresAt = () => this.resumeExpiry - getResumeConfirmation = () => ({ - v: 1 as const, - reqId: 'confirm-1', - currentVersion: 2, - acceptedAs: 'current' as const, - renewed: true, - resumeExpiresAt: this.resumeExpiry - }) - getFailure = () => this.failure -} - -class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { - private path: MobileConnectionPath - private generation = 1 - - constructor(state: ConnectionState, path: MobileConnectionPath) { - super(state) - this.path = path - } - - migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { - if (session.getState() !== 'connected') { - session.close() - throw new Error(`replacement session ${session.getState()}`) - } - this.path = path - this.generation += 1 - this.publishState('connected') - }) - suspendActiveSession = vi.fn(() => this.publishState('disconnected')) - getActivePath = () => this.path - getGeneration = () => this.generation -} - -const relay = { - v: 1 as const, - directorUrl: 'https://relay.onorca.dev', - cellUrl: 'https://relay-c1.onorca.dev', - assignmentEpoch: 7, - relayHostId: 'AbCdEf0123_-xyZ9', - e2eeFraming: 2 as const -} -const host: HostProfile = { - id: 'host-1', - name: 'Blue Whale', - endpoint: 'ws://192.168.1.10:6768', - deviceToken: 'device-token', - publicKeyB64: 'A'.repeat(44), - lastConnected: 1, - endpoints: [ - { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, - { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } - ], - relayHostId: relay.relayHostId, - relay -} -const bundle: MobileRelayCredentialBundle = { - v: 1, - hostId: host.id, - deviceToken: host.deviceToken, - current: { - token: 'A'.repeat(43), - hash: 'B'.repeat(43), - version: 2, - expiresAt: Number.MAX_SAFE_INTEGER - } -} - -function dependencies( - overrides: Partial = {} -): MobileEndpointSupervisorDependencies { - return { - openDirect: vi.fn(() => new FakeSession('connected')), - openRelay: vi.fn(() => new FakeRelaySession('connected')), - resolveRelay: vi.fn(async ({ relay }) => relay), - readBundle: vi.fn(async () => bundle), - writeBundle: vi.fn(async () => {}), - saveHost: vi.fn(async () => {}), - now: Date.now, - randomBytes: (length) => new Uint8Array(length).fill(1), - setTimer: setTimeout, - clearTimer: clearTimeout, - ...overrides - } -} - -function mockCredentialRotation(logical: FakeLogicalClient): void { - let installResult: Record | null = null - logical.sendRequest.mockImplementation(async (method, params) => { - const request = params as { installReqId?: string; reqId?: string } - if (method === 'pairing.provisionRelay') { - installResult = { - v: 1, - reqId: request.reqId, - authorizationMode: 'authenticated-direct', - currentVersion: 3, - resumeExpiresAt: Date.now() + 300_000, - graceExpiresAt: Date.now() + 60_000 - } - return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } } - } - return { - id: 'rpc-1', - ok: true, - result: { - v: 1, - relay, - installStatus: installResult - ? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult } - : { v: 1, reqId: request.installReqId, state: 'not-found' } - }, - _meta: { runtimeId: 'runtime-1' } - } - }) -} - describe('mobile endpoint supervisor', () => { beforeEach(() => { vi.useFakeTimers() @@ -805,6 +647,27 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) + it('skips forced rotation for a session resumed without renewal', async () => { + // Why: renewed=false means a re-resume provably returns the same unchanged + // deadline; rotating anyway churned one session replacement per clamp floor. + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi.fn( + () => new FakeRelaySession('connected', null, Date.now() + 31_000, false) + ) + const deps = dependencies({ + openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + expect(openRelay).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(openRelay).toHaveBeenCalledTimes(1) + supervisor.stop() + }) + it('keeps a fatal lease-replacement gate after the active relay later drops', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index 77ffbbde3db8..be295fdcdda1 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -265,8 +265,11 @@ export class MobileEndpointSupervisor { // Why: rotate against the resume credential's expiry, never the hello's // leaseExpiresAt — that field is the cell's ~10s attach-reservation // deadline, and using it forced a session replacement every second. + // Why: renewed=false means a re-resume provably returns the same + // unchanged deadline — rotating then just churns one replacement per + // clamp floor until a fresh credential arrives over direct or disk. this.leaseRotation.scheduleFromLease( - this.stopped || !this.foreground + this.stopped || !this.foreground || confirmation?.renewed === false ? null : (confirmation?.resumeExpiresAt ?? session.getResumeExpiresAt()) ) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index cc640d4d7e75..4f50878c6d55 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -111,6 +111,48 @@ describe('relay reconnect controller', () => { expect(onRetry).toHaveBeenCalledTimes(3) }) + it('mints the gate pass when arming a no-bundle reprobe under a held gate', () => { + // Why: an external-signal gate never records rejected versions, so the + // no-dialable-bundle path must still mint the tick's pass token — a plain + // cooldown tick bounces off shouldDefer and doubles the effective cadence. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.registerFailure(new MobileE2EEAuthenticationError()) + expect(reconnect.shouldDefer()).toBe(true) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(reconnect.shouldDefer()).toBe(false) + // The gated attempt finds no dialable credential at all and re-arms. + reconnect.armCredentialReprobe() + + vi.advanceTimersByTime(120_000) + expect(onRetry).toHaveBeenCalledTimes(2) + expect(reconnect.shouldDefer()).toBe(false) + }) + + it('does not arm a gate reprobe timer when the supervisor declined retries', () => { + // Why: scheduleRetry=false means background or stopped — a tick firing + // there wakes nothing useful, and the next foreground resume re-arms. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new MobileE2EEAuthenticationError(), false) + expect(vi.getTimerCount()).toBe(0) + reconnect.registerFailure(new RelayOuterError(4401), false) + expect(vi.getTimerCount()).toBe(0) + reconnect.registerFailure(new RelayOuterError(4429), false) + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + expect(onRetry).not.toHaveBeenCalled() + + // Resume restores the gated cadence instead of stalling forever. + reconnect.handleForeground(logical, false) + expect(reconnect.shouldDefer()).toBe(true) + expect(vi.getTimerCount()).toBe(1) + }) + it('does not let an orphaned reprobe timer swallow the next fast backoff', () => { const onRetry = vi.fn() const reconnect = createController(onRetry) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index f2a7cad6b05b..9ec5c1398bc9 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -6,24 +6,10 @@ import { import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { RelayOuterError } from './mobile-relay-e2ee-link' +import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState } from './types' -// Why: relay resume closes and silent cellular NAT rebinds otherwise cause -// immediate re-dials that ping-pong the phone between connected and disconnected. -const RELAY_BACKOFF_MIN_MS = 250 -const RELAY_BACKOFF_BASE_MS = 500 -const RELAY_BACKOFF_CEILING_MS = 30_000 -const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS -const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 -const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 -// Why: gates must slow recovery down, never end it — a relay-only phone has no -// direct path to refresh credentials, so a timerless gate is a permanent outage. -// The cadence escalates and jitters so a permanently revoked device is not a -// forever one-minute beacon and a fleet-wide gating event cannot phase-align. -const RELAY_GATE_REPROBE_BASE_MS = 60_000 -const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000 - export type RelayReconnectDependencies = { now: () => number randomBytes: (length: number) => Uint8Array @@ -43,11 +29,14 @@ export class RelayReconnectController { private gateReprobePending = false private gateReprobeStreak = 0 private readonly rejectedCredentialVersions = new Set() + private readonly delays: RelayRetryDelays constructor( private readonly dependencies: RelayReconnectDependencies, private readonly onRetry: (forceReplacement?: boolean) => void - ) {} + ) { + this.delays = new RelayRetryDelays(dependencies.randomBytes) + } handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void { if (!wasForeground) { @@ -156,10 +145,17 @@ export class RelayReconnectController { this.scheduleGateReprobe() return } + if (this.recoveryGate) { + // Why: under a held gate the tick must mint its pass token — a plain + // cooldown tick bounces off shouldDefer and doubles the effective cadence. + this.clearTimer() + this.scheduleGateReprobe() + return + } // Why: a merely missing or expired bundle must not enter the credential // gate — that gate forces a rotation on the next direct connect. A plain // cooldown retries the read on the same escalating cadence. - const delay = this.gateReprobeDelayMs() + const delay = this.delays.gateReprobeDelayMs(this.gateReprobeStreak) this.nextAttemptAt = this.dependencies.now() + delay this.clearTimer() this.scheduleReprobeTick(delay, false) @@ -219,9 +215,12 @@ export class RelayReconnectController { this.recoveryGate === 'fresh-credential' || (this.recoveryGate === 'external-signal' && recovery?.kind !== 'disable-relay-credential') ) { - // Why: a failed reprobe stays gated, but the slow cadence must keep going. + // Why: a failed reprobe stays gated, but the slow cadence must keep going + // — unless the supervisor is backgrounded/stopped; resume re-arms it. this.clearTimer() - this.scheduleGateReprobe() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } const now = this.dependencies.now() @@ -236,7 +235,9 @@ export class RelayReconnectController { this.activeRelayConnectedAt = null this.consecutiveFailures += 1 const delay = - recovery?.kind === 'retry-after-host-offline' ? this.hostOfflineDelayMs() : this.delayMs() + recovery?.kind === 'retry-after-host-offline' + ? this.delays.hostOfflineDelayMs() + : this.delays.transportDelayMs(this.consecutiveFailures) this.nextAttemptAt = now + delay if (error instanceof MobileE2EEAuthenticationError) { // Why: an E2EE rejection is usually pairing revocation, but it also fires @@ -244,7 +245,9 @@ export class RelayReconnectController { // reprobe slowly instead of waiting forever for a UI nudge. this.recoveryGate = 'external-signal' this.clearTimer() - this.scheduleGateReprobe() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } if (recovery?.kind === 'disable-relay-credential') { @@ -252,7 +255,9 @@ export class RelayReconnectController { // alive — the caller re-reads durable state before each gated attempt. this.recoveryGate = 'fresh-credential' this.clearTimer() - this.scheduleGateReprobe() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } if (recovery?.kind === 'retry-after-host-offline') { @@ -316,7 +321,7 @@ export class RelayReconnectController { } private scheduleGateReprobe(): void { - this.scheduleReprobeTick(this.gateReprobeDelayMs(), true) + this.scheduleReprobeTick(this.delays.gateReprobeDelayMs(this.gateReprobeStreak), true) } private scheduleReprobeTick(delay: number, mintToken: boolean): void { @@ -337,14 +342,6 @@ export class RelayReconnectController { }, delay) } - private gateReprobeDelayMs(): number { - const base = Math.min( - RELAY_GATE_REPROBE_CEILING_MS, - RELAY_GATE_REPROBE_BASE_MS * 2 ** Math.min(this.gateReprobeStreak, 8) - ) - return Math.floor(base * (0.75 + 0.5 * this.jitterFraction())) - } - // Why: clearing a gate must also drop its timer, pending tick, and cadence — // an orphaned reprobe timer would otherwise swallow the next fast backoff. private liftGate(): void { @@ -353,21 +350,4 @@ export class RelayReconnectController { this.gateReprobeStreak = 0 this.clearTimer() } - - private delayMs(): number { - const exponent = Math.max(0, this.consecutiveFailures - 1) - const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) - // Full jitter (uniform in [0, cap)), floored so retries never busy-loop. - return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) - } - - private hostOfflineDelayMs(): number { - const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS - return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) - } - - private jitterFraction(): number { - const [high, low] = this.dependencies.randomBytes(2) - return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00 - } } diff --git a/mobile/src/transport/mobile-relay-retry-delays.ts b/mobile/src/transport/mobile-relay-retry-delays.ts new file mode 100644 index 000000000000..6ff145acdb46 --- /dev/null +++ b/mobile/src/transport/mobile-relay-retry-delays.ts @@ -0,0 +1,45 @@ +// Why: relay resume closes and silent cellular NAT rebinds otherwise cause +// immediate re-dials that ping-pong the phone between connected and disconnected. +const RELAY_BACKOFF_MIN_MS = 250 +const RELAY_BACKOFF_BASE_MS = 500 +const RELAY_BACKOFF_CEILING_MS = 30_000 +export const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS +const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 +const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 +// Why: gates must slow recovery down, never end it — a relay-only phone has no +// direct path to refresh credentials, so a timerless gate is a permanent outage. +// The cadence escalates and jitters so a permanently revoked device is not a +// forever one-minute beacon and a fleet-wide gating event cannot phase-align. +const RELAY_GATE_REPROBE_BASE_MS = 60_000 +const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000 + +// Every relay retry delay in one place: transport backoff, known-offline +// polling, and the escalating gated reprobe cadence, all jittered. +export class RelayRetryDelays { + constructor(private readonly randomBytes: (length: number) => Uint8Array) {} + + transportDelayMs(consecutiveFailures: number): number { + const exponent = Math.max(0, consecutiveFailures - 1) + const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) + // Full jitter (uniform in [0, cap)), floored so retries never busy-loop. + return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) + } + + hostOfflineDelayMs(): number { + const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS + return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) + } + + gateReprobeDelayMs(streak: number): number { + const base = Math.min( + RELAY_GATE_REPROBE_CEILING_MS, + RELAY_GATE_REPROBE_BASE_MS * 2 ** Math.min(streak, 8) + ) + return Math.floor(base * (0.75 + 0.5 * this.jitterFraction())) + } + + private jitterFraction(): number { + const [high, low] = this.randomBytes(2) + return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00 + } +}