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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user