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