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
+2
View File
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- Remote access: pairing QR codes created while the app is open through a public domain (for example behind a reverse proxy) now include that domain as a connection address, so paired phones can reach the server over it instead of relying only on the local network address or the relay.
- Mobile: a brief network hiccup when opening or returning to the app no longer bounces a working connection to the connect screen — the app retries in the background and reconnects on its own, while an unreachable server shows the connect screen within a few seconds instead of holding the launch logo.
- Mobile: long-pressing the logo on the connect screen (or the instances list) opens a connection log with a copy button, for reporting connection problems.
- Usage: quota limits enabled for display now refresh every three minutes on desktop, mobile, and VS Code, with a manual refresh action available at any time.
## [1.18.2] - 2026-08-10
+79 -34
View File
@@ -54,7 +54,7 @@ import { MobileSessionsSheet } from './MobileSessionsSheet';
import { MobileFullscreenSurface } from './MobileFullscreenSurface';
import { MobileWorkspaceDrawer, type MobileWorkspaceTab } from './MobileWorkspaceDrawer';
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
import { autoConnectLastInstance, getAutoConnectTargetLabel, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections';
import { autoConnectLastInstance, getAutoConnectTargetLabel, logMobileConnectEvent, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections';
import { isCapacitorMobileApp, useNativeAndroidBackButton, useNativeMobileChrome, useNativeMobileLifecycle } from './mobileNativeChrome';
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
import { useAppFontEffects } from './useAppFontEffects';
@@ -660,9 +660,11 @@ export function MobileApp({ apis }: MobileAppProps) {
// saved instance instead of dead-ending on the connect screen until the
// user restarts the app. Success fires runtime-endpoint-changed, which
// re-bootstraps everything.
logMobileConnectEvent('resume:auto-connect', {});
void autoConnectLastInstance();
return;
}
logMobileConnectEvent('resume:reprobe', {});
// Re-probe the active device's transports on resume: the network may have
// changed while the app slept, so hot-switch LAN⇄relay if a better transport
@@ -675,7 +677,8 @@ export function MobileApp({ apis }: MobileAppProps) {
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
};
const disconnect = () => {
const disconnect = (reason: string) => {
logMobileConnectEvent('resume:disconnect', { reason });
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
setConnectionEpoch((value) => value + 1);
};
@@ -683,36 +686,50 @@ export function MobileApp({ apis }: MobileAppProps) {
void reprobeActiveConnection().then((outcome) => {
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
if (outcome === 'no-connection') {
disconnect();
disconnect('no-connection');
return;
}
if (outcome === 'needs-login') {
// Token explicitly rejected (revoked/expired) — tell the user why they
// land back on the connect screen instead of silently bouncing them.
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
disconnect();
disconnect('needs-login');
return;
}
if (outcome === 'unreachable') {
// Right after a resume or Wi-Fi switch the network is often still
// settling (on Android without a SIM there is NO connectivity at all for
// a few seconds), so a single fast probe races the network coming up.
// Retry once after a grace period before tearing the connection down.
window.setTimeout(() => {
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
void reprobeActiveConnection().then((retry) => {
// settling (Android without a SIM has NO connectivity for a few
// seconds; a WireGuard tunnel re-handshakes; a relay cold start pays
// TLS + WS + E2EE before it can answer), so a single fast probe races
// the network coming up. Retry on a widening grace ladder before
// tearing the connection down — the last attempt runs with the full
// connect budget so slow-but-alive transports get a real chance.
const retryDelaysMs = [4000, 10000];
const retryAt = (attempt: number) => {
window.setTimeout(() => {
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
if (retry === 'switched') return;
if (retry === 'unchanged') {
refreshInPlace();
return;
}
if (retry === 'needs-login') {
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
}
disconnect();
});
}, 4000);
const lastAttempt = attempt === retryDelaysMs.length - 1;
void reprobeActiveConnection({ fast: !lastAttempt }).then((retry) => {
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
if (retry === 'switched') return;
if (retry === 'unchanged') {
refreshInPlace();
return;
}
if (retry === 'needs-login') {
setAutoConnectNotice({ kind: 'auth-expired', label: getAutoConnectTargetLabel() ?? '' });
disconnect('retry-needs-login');
return;
}
if (!lastAttempt) {
retryAt(attempt + 1);
return;
}
disconnect(`retry-${retry}`);
});
}, retryDelaysMs[attempt]);
};
retryAt(0);
return;
}
if (outcome === 'switched') return;
@@ -764,6 +781,15 @@ export function MobileApp({ apis }: MobileAppProps) {
// stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too.
React.useEffect(() => {
return subscribeRuntimeEndpointChanged((detail) => {
// Catch-all trail entry: EVERY endpoint change lands here regardless of
// which code path triggered it, so a "kicked to the connect screen"
// report always shows what dropped the runtime even when the trigger
// itself is not instrumented.
logMobileConnectEvent('endpoint:changed', {
runtimeKey: detail.runtimeKey || 'none',
previousRuntimeKey: detail.previousRuntimeKey || 'none',
connected: Boolean(detail.apiBaseUrl),
});
// A LAN⇄relay swap for the SAME device keeps the runtime key stable. Treat
// that as a transport-only change: rebind the sync layer to the new
// transport but keep the user's session/connection state — no reconnecting
@@ -800,19 +826,30 @@ export function MobileApp({ apis }: MobileAppProps) {
}
let cancelled = false;
setAutoConnectPhase('attempting');
void autoConnectLastInstance()
.catch((): AutoConnectOutcome => ({ status: 'no-candidate' }))
.then((outcome) => {
if (cancelled) return;
// Landing on the connect screen silently reads as data loss — say WHY
// the saved instance didn't come back (unreachable vs revoked auth).
if (outcome.status === 'unreachable') {
setAutoConnectNotice({ kind: 'unreachable', label: outcome.label });
} else if (outcome.status === 'needs-login') {
setAutoConnectNotice({ kind: 'auth-expired', label: outcome.label });
}
setAutoConnectPhase('done');
});
void (async () => {
const outcome = await autoConnectLastInstance()
.catch((): AutoConnectOutcome => ({ status: 'no-candidate' }));
if (cancelled) return;
// Landing on the connect screen silently reads as data loss — say WHY
// the saved instance didn't come back (unreachable vs revoked auth).
if (outcome.status === 'unreachable') {
setAutoConnectNotice({ kind: 'unreachable', label: outcome.label });
} else if (outcome.status === 'needs-login') {
setAutoConnectNotice({ kind: 'auth-expired', label: outcome.label });
}
// Release the splash on the fast verdict — a dead server must not pin
// the logo for the full connect budget. The fast probe races a
// just-woken network/relay (WireGuard re-handshake, relay TLS + WS +
// E2EE cold start), so a false "unreachable" is common right after
// launch: retry once IN THE BACKGROUND with the full budget. A success
// switches the runtime and the app moves in from the connect screen on
// its own; a manual connect the user started meanwhile wins via
// skipIfConnected.
setAutoConnectPhase('done');
if (outcome.status === 'unreachable') {
void autoConnectLastInstance({ fast: false, skipIfConnected: true }).catch(() => null);
}
})();
return () => {
cancelled = true;
};
@@ -833,6 +870,7 @@ export function MobileApp({ apis }: MobileAppProps) {
if (!isNativeMobileApp || !getRuntimeApiBaseUrl()) return;
let cancelled = false;
const dropToConnectScreen = (notice: MobileConnectionNotice | null) => {
logMobileConnectEvent('cold-launch:drop', { kind: notice?.kind ?? 'unknown' });
if (notice) setAutoConnectNotice(notice);
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
setConnectionEpoch((value) => value + 1);
@@ -847,7 +885,14 @@ export function MobileApp({ apis }: MobileAppProps) {
return;
}
if (outcome === 'unreachable') {
// A fast probe racing the just-woken network/relay produces false
// "unreachable" verdicts (seen in the field: the same LAN candidate
// refuses on launch and answers 200 two minutes later). Show the
// connect screen on the fast verdict — no splash hostage — and retry
// once in the background with the full budget; a success reconnects
// the app from the connect screen on its own.
dropToConnectScreen(label ? { kind: 'unreachable', label } : null);
void autoConnectLastInstance({ fast: false, skipIfConnected: true }).catch(() => null);
return;
}
// 'no-connection': at cold start the runtime key may not map to a saved
+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) });