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
+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