fix(mobile): tolerate transient connect failures without bouncing the user

A single fast probe (2.5s per transport) used to be the only chance a
connection got on cold launch and resume, so a just-woken network, a
WireGuard re-handshake, or a relay cold start (TLS + WS + E2EE) regularly
produced false "unreachable" verdicts that kicked the user to the connect
screen. Now:

- cold launch releases the splash on the fast verdict and retries once in
  the background with the full connect budget — a reachable instance
  reconnects on its own, and a manual connect started meanwhile wins;
- resume retries on a 4s/10s ladder, the last attempt with the full budget,
  before tearing the connection down; needs-login still disconnects
  immediately on every path;
- full-budget relay probes are capped at the shared 8s connect budget
  instead of inheriting the 15s relay session default, so a genuinely dead
  server does not pin the retry for 15 extra seconds.

Probe steps, budgets, and retry decisions all land in the connection log.
This commit is contained in:
Bohdan Triapitsyn
2026-08-13 00:07:00 +03:00
parent dc9c5f8e3e
commit e7b864e9ae
3 changed files with 141 additions and 51 deletions
+60 -17
View File
@@ -26,6 +26,8 @@ import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { recordMobileConnectDebug } from './mobileConnectionDebug';
const MOBILE_CONNECTIONS_STORAGE_KEY = 'openchamber.mobile.connections.v1';
const MOBILE_SECURE_STORAGE_PREFIX = 'openchamber.mobile.';
const MOBILE_DEVICE_ID_STORAGE_KEY = 'openchamber.mobile.deviceId';
@@ -304,11 +306,22 @@ const logDetail = (detail: Record<string, unknown>): string => {
};
const logConnect = (step: string, detail: Record<string, unknown> = {}): void => {
console.info('[mobile-connect]', step, logDetail(detail));
const serialized = logDetail(detail);
console.info('[mobile-connect]', step, serialized);
recordMobileConnectDebug(step, serialized);
};
// Exported for surfaces that participate in the connection lifecycle outside
// this module (resume/online re-probes in MobileApp) so their decisions land in
// the same console + debug-panel trail as the probes themselves.
export const logMobileConnectEvent = (step: string, detail: Record<string, unknown> = {}): void => {
logConnect(step, detail);
};
const logStorage = (step: string, detail: Record<string, unknown> = {}): void => {
console.info('[mobile-storage]', step, logDetail(detail));
const serialized = logDetail(detail);
console.info('[mobile-storage]', step, serialized);
recordMobileConnectDebug(step, serialized);
};
const parseMaybeJson = (value: unknown): unknown => {
@@ -347,14 +360,14 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<Mobil
json: async () => parseMaybeJson(response.data),
};
} catch (error) {
console.warn('[mobile-connect]', 'native-http failed', logDetail({ url, error: error instanceof Error ? error.message : String(error) }));
logConnect('native-http:failed', { url, error: error instanceof Error ? error.message : String(error) });
return null;
}
};
const browserFetchRequest = async (url: string, init?: RequestInit): Promise<MobileFetchResponse | null> => {
const response = await fetch(url, init).catch((error) => {
console.warn('[mobile-connect]', 'browser-fetch failed', logDetail({ url, error: error instanceof Error ? error.message : String(error) }));
logConnect('browser-fetch:failed', { url, error: error instanceof Error ? error.message : String(error) });
return null;
});
if (!response) return null;
@@ -835,6 +848,7 @@ const probeConnectionCandidates = async (
// /health is unauthenticated by design — never send the bearer token to an
// address whose identity has not been checked yet.
const health = await requestWithTimeout(`${url}/health`, { method: 'GET' }, requestOptions);
logConnect('probe:direct:health', { url, ok: health?.ok === true, status: health?.status ?? null, source: health?.source ?? null });
if (!health?.ok) continue;
if (expectedServerId) {
const payload = await health.json().catch(() => null);
@@ -850,6 +864,7 @@ const probeConnectionCandidates = async (
// the probe passes, and the app dies later on bootstrap's bearer-only
// requests. Cookie auth stays for the token-less (browser) flow.
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions);
logConnect('probe:direct:session', { url, ok: session?.ok === true, status: session?.status ?? null, source: session?.source ?? null, hasToken: Boolean(token) });
if (session?.status === 401) return { status: 'needs-login' };
if (!session || (!session.ok && session.status !== 404)) continue;
const status = await readSessionStatus(session);
@@ -868,11 +883,15 @@ const probeConnectionCandidates = async (
if (!relayCandidate) return { status: 'unreachable' };
// keepTunnel: an 'ok' probe hands its live tunnel to switchToTransport,
// which adopts it as the runtime tunnel — no second connect + handshake.
// Full-budget probes align relay with the direct-transport connect budget
// (8s) instead of inheriting probeRelaySession's 15s default: 8s is ample
// for TLS + WS + E2EE handshake, and a dead host must not pin the connect
// splash (or a resume retry) for 15 extra seconds.
const { outcome, tunnel } = await probeRelaySession(
relayCandidate.relay,
token,
undefined,
options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : undefined,
options?.fast ? MOBILE_FAST_PROBE_TIMEOUT_MS : MOBILE_CONNECT_TIMEOUT_MS,
{ keepTunnel: true },
);
if (outcome === 'ok') return { status: 'ok', transport: { kind: 'relay', relay: relayCandidate.relay, tunnel } };
@@ -989,9 +1008,11 @@ export type AutoConnectOutcome =
/** The saved token was rejected (expired/revoked) — the user must sign in again. */
| { status: 'needs-login'; label: string };
export const autoConnectLastInstance = async (): Promise<AutoConnectOutcome> => {
export const autoConnectLastInstance = async (options?: { fast?: boolean; skipIfConnected?: boolean }): Promise<AutoConnectOutcome> => {
const fast = options?.fast !== false;
await migrateLegacyInlineTokens();
const candidate = readConnections()[0]; // sorted most-recent-first
logConnect('auto-connect:start', { hasCandidate: Boolean(candidate), fast });
if (!candidate) return { status: 'no-candidate' };
// The runtime transport needs a bearer token; only auto-connect when one is
@@ -1010,13 +1031,24 @@ export const autoConnectLastInstance = async (): Promise<AutoConnectOutcome> =>
if (!token) return { status: 'no-candidate' };
}
// Fast probe: the cold-launch splash should decide in a couple of seconds,
// not sit through the full connect timeouts on a dead LAN candidate. A slow
// network that fails the fast probe still lands on the connect screen where
// a manual tap retries with the full budget.
const result = await probeConnectionCandidates(candidate.candidates, token, { fast: true });
// Fast probe by default: the cold-launch splash should decide in a couple of
// seconds, not sit through the full connect timeouts on a dead LAN candidate.
// Callers retrying after an 'unreachable' verdict pass fast:false so the slow
// retry gets the full connect budget (relay cold starts — TLS + WS + E2EE
// handshake — regularly overrun the fast window).
const result = await probeConnectionCandidates(candidate.candidates, token, { fast });
logConnect('auto-connect:probe', { status: result.status, candidates: candidate.candidates.map((c) => c.kind) });
if (result.status === 'needs-login') return { status: 'needs-login', label: candidate.label };
if (result.status !== 'ok') return { status: 'unreachable', label: candidate.label };
// Background-retry guard: while this slow probe ran, the user may have
// connected manually from the connect screen. Their choice wins — discard
// this result instead of hijacking the runtime (close the probe's unused
// relay tunnel; a direct transport holds nothing).
if (options?.skipIfConnected && getRuntimeApiBaseUrl()) {
if (result.transport.kind === 'relay') result.transport.tunnel?.close();
logConnect('auto-connect:superseded', {});
return { status: 'no-candidate' };
}
await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token)
switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) });
return { status: 'connected' };
@@ -1163,9 +1195,13 @@ export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'needs-l
// validates the current transport over its live channel; only if that is dead does
// it fall through to the lower-priority candidates. 'unchanged' → keep the runtime
// and just refresh; 'unreachable'/'no-connection' → show the connect screen.
export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
export const reprobeActiveConnection = async (options?: { fast?: boolean }): Promise<ReprobeOutcome> => {
const fast = options?.fast !== false;
const active = findActiveConnection();
if (!active) return 'no-connection';
if (!active) {
logConnect('reprobe:no-connection', { runtimeKey: Boolean(getRuntimeKey()) });
return 'no-connection';
}
let token: string | undefined;
if (isCapacitorApp()) {
@@ -1173,7 +1209,11 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
} else {
token = active.clientToken;
}
if (!token) return 'unreachable';
if (!token) {
logConnect('reprobe:no-token', { hasToken: Boolean(active.hasToken) });
return 'unreachable';
}
logConnect('reprobe:start', { candidates: active.candidates.map((c) => c.kind), fast });
const currentIndex = active.candidates.findIndex(
(candidate) => transportMatchesCurrentRuntime(candidate.kind === 'relay' ? { kind: 'relay', relay: candidate.relay } : { kind: 'direct', url: candidate.url }),
@@ -1181,7 +1221,8 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
// 1. A higher-priority transport becoming reachable means "came home" (relay → LAN).
const higher = currentIndex >= 0 ? active.candidates.slice(0, currentIndex) : active.candidates;
const better = await probeConnectionCandidates(higher, token, { fast: true });
const better = await probeConnectionCandidates(higher, token, { fast });
logConnect('reprobe:better', { status: better.status, probed: higher.length });
if (better.status === 'ok') {
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) });
@@ -1192,7 +1233,8 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
// 2. No better transport — is the current one still alive on its live channel?
if (currentIndex >= 0) {
const stillValid = await validateActiveRuntimeSession({ url: getRuntimeApiBaseUrl(), clientToken: token }, { fast: true });
const stillValid = await validateActiveRuntimeSession({ url: getRuntimeApiBaseUrl(), clientToken: token }, { fast });
logConnect('reprobe:current', { stillValid });
if (stillValid) {
// Still on the same transport (typically: woke up on the relay, old LAN
// candidate dead). Ask the server for its current LAN addresses in the
@@ -1205,7 +1247,8 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
// 3. Current transport is dead — fall through to lower-priority candidates.
const lower = currentIndex >= 0 ? active.candidates.slice(currentIndex + 1) : [];
const fallback = await probeConnectionCandidates(lower, token, { fast: true });
const fallback = await probeConnectionCandidates(lower, token, { fast });
logConnect('reprobe:fallback', { status: fallback.status, probed: lower.length });
if (fallback.status === 'ok') {
await upsertMobileConnection({ id: active.id, label: active.label, candidates: active.candidates });
switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) });