diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 16baabe91..71bda7b64 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -552,6 +552,17 @@ export function HostScreen({ } }, [connState, client]) + useFocusEffect( + useCallback(() => { + // Why: opening the host is a strong user signal — reset a backed-off or + // trickling reconnect loop (and probe a possibly half-open socket) + // immediately instead of waiting out its timer. Deps stay empty so this + // fires per focus transition, not per connection-state change; nudging + // on every reconnecting↔connecting flip would defeat the backoff. + clientRef.current?.notifyForeground() + }, []) + ) + useFocusEffect( useCallback(() => { // The embedded sidebar drives its own polling below; focus never fires diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c4701a302..b556170b4 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -62,7 +62,10 @@ import { useReconnectAttempt, useLastConnectedAt } from '../../../../src/transport/client-context' -import { classifyConnection } from '../../../../src/transport/connection-health' +import { + classifyConnection, + verdictDisplayLabel +} from '../../../../src/transport/connection-health' import { useResponsiveLayout } from '../../../../src/layout/responsive-layout' import { type ActivePanel, @@ -1000,6 +1003,9 @@ export default function SessionScreen() { const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) + // Why: state (not a ref) — the connection verdict needs a re-render once + // the endpoint loads so the Tailscale hint can appear. + const [hostEndpoint, setHostEndpoint] = useState(null) const clientRef = useRef(null) const connStateRef = useRef(connState) // Why: measured once from TerminalWebView on mount, then passed with every @@ -2453,6 +2459,7 @@ export default function SessionScreen() { const host = hosts.find((h) => h.id === hostId) if (host) { deviceTokenRef.current = host.deviceToken + setHostEndpoint(host.endpoint) } }) return () => { @@ -4265,13 +4272,14 @@ export default function SessionScreen() { void handleCreateTerminal() }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) - // Why: the reconnect loop parks at its give-up cap; without an in-session - // affordance the only recovery is leaving the screen or restarting the - // app (issue #5049). Surface tap-to-retry once the verdict escalates. + // Why: the reconnect loop slows to a 90s trickle at its give-up cap; + // surface tap-to-retry once the verdict escalates so recovery doesn't + // wait out the trickle timer (issue #5049). const connectionVerdict = classifyConnection({ state: connState, reconnectAttempts, - lastConnectedAt + lastConnectedAt, + endpoint: hostEndpoint }) const showConnectionRetry = connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' @@ -4284,7 +4292,7 @@ export default function SessionScreen() { ? '1 tab' : `${visibleTabs.length} tabs` : showConnectionRetry - ? `${connectionVerdict.label} — tap to retry` + ? `${verdictDisplayLabel(connectionVerdict)} — tap to retry` : MOBILE_SESSION_STATUS_LABELS[connState] // Why: keep safe-area padding in layout at all times, then visually translate diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index acaf97b76..eaee0e5f9 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -34,7 +34,7 @@ import { useForceReconnect, usePrimeHosts } from '../src/transport/client-context' -import { classifyConnection } from '../src/transport/connection-health' +import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' @@ -827,7 +827,8 @@ export default function HomeScreen() { const verdict = classifyConnection({ state, reconnectAttempts: attempts, - lastConnectedAt + lastConnectedAt, + endpoint: item.endpoint }) const isError = verdict.kind === 'warning' || @@ -859,7 +860,7 @@ export default function HomeScreen() { - {verdict.label} + {verdictDisplayLabel(verdict)} {connected && info ? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}` : ''} diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index 0a1a63b7c..e9d7a34ee 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -14,11 +14,6 @@ import { ChevronLeft, ChevronDown, ChevronUp, - WifiOff, - Shield, - Monitor, - Clock, - Globe, Activity, CheckCircle2, XCircle, @@ -30,7 +25,12 @@ import { startDiagnosticFetchTimeout, type DiagnosticFetchTimeout } from '../src/diagnostics/diagnostic-fetch-timeout' -import { formatEndpoint, testHostReachability } from '../src/diagnostics/host-reachability' +import { + formatEndpoint, + testHostReachability, + unreachableHostDetail +} from '../src/diagnostics/host-reachability' +import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues' type DiagnosticStatus = 'idle' | 'running' | 'done' @@ -40,66 +40,6 @@ type CheckResult = { detail: string } -type TroubleshootSection = { - id: string - icon: React.ReactNode - title: string - steps: string[] -} - -const sections: TroubleshootSection[] = [ - { - id: 'wifi', - icon: , - title: 'Different WiFi Networks', - steps: [ - 'Both devices must be on the same local network.', - 'Ethernet and WiFi must share the same subnet.', - 'Try reconnecting WiFi on both devices.' - ] - }, - { - id: 'firewall', - icon: , - title: 'Firewall Blocking Port 6768', - steps: [ - 'macOS: System Settings → Network → Firewall — allow Orca.', - 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', - 'Linux: sudo ufw allow 6768', - 'Corporate/school networks may block P2P — try a personal hotspot.' - ] - }, - { - id: 'desktop', - icon: , - title: 'Desktop App Not Running', - steps: [ - 'Orca must be open on your desktop to accept connections.', - 'Try restarting Orca — the companion server starts on launch.', - 'After an update, you may need to re-pair via QR code.' - ] - }, - { - id: 'timeout', - icon: , - title: 'Connection Timeout', - steps: [ - 'Check WiFi signal strength on your phone.', - 'Go back to the host list and tap your host to retry.', - 'Restart both apps if timeouts persist.' - ] - }, - { - id: 'vpn', - icon: , - title: 'VPN Interference', - steps: [ - 'VPNs can route local traffic through a remote server.', - 'Disable the VPN or enable split tunneling / "Allow LAN".' - ] - } -] - function StatusIcon({ status }: { status: CheckResult['status'] }) { switch (status) { case 'pass': @@ -211,7 +151,7 @@ export default function TroubleshootScreen() { status: reachable ? 'pass' : 'fail', detail: reachable ? `Reachable at ${formatEndpoint(host.endpoint)}` - : `Cannot reach ${formatEndpoint(host.endpoint)}` + : unreachableHostDetail(host.endpoint) }) setChecks([...results]) } @@ -295,7 +235,7 @@ export default function TroubleshootScreen() { Common issues - {sections.map((section, i) => ( + {troubleshootCommonIssues.map((section, i) => ( {i > 0 && } { + it('points at Tailscale for tailnet CGNAT endpoints', () => { + expect(unreachableHostDetail('ws://100.65.9.106:6768')).toBe( + 'Cannot reach 100.65.9.106:6768 — check Tailscale' + ) + }) + + it('points at Tailscale for MagicDNS endpoints', () => { + expect(unreachableHostDetail('ws://my-desktop.tailnet-1234.ts.net:6768')).toBe( + 'Cannot reach my-desktop.tailnet-1234.ts.net:6768 — check Tailscale' + ) + }) + + it('stays generic for LAN endpoints', () => { + expect(unreachableHostDetail('ws://192.168.1.50:6768')).toBe('Cannot reach 192.168.1.50:6768') + }) + + it('does not treat non-CGNAT 100.x addresses as Tailscale', () => { + expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768') + }) +}) describe('testHostReachability', () => { it('returns false without leaving timers when WebSocket rejects a malformed endpoint', async () => { diff --git a/mobile/src/diagnostics/host-reachability.ts b/mobile/src/diagnostics/host-reachability.ts index 7ae292126..495d65a97 100644 --- a/mobile/src/diagnostics/host-reachability.ts +++ b/mobile/src/diagnostics/host-reachability.ts @@ -1,3 +1,5 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' + const HOST_REACHABILITY_TIMEOUT_MS = 4000 // Why: troubleshooting needs a cheap endpoint probe without completing the @@ -56,3 +58,13 @@ export function formatEndpoint(endpoint: string): string { return endpoint } } + +// Why: an unreachable 100.x/*.ts.net host almost always means the phone's +// Tailscale tunnel is down or wedged (known iOS failure mode, fixed by +// toggling the VPN) — point at that instead of a bare "Cannot reach". +export function unreachableHostDetail(endpoint: string): string { + if (isTailscaleEndpoint(endpoint)) { + return `Cannot reach ${formatEndpoint(endpoint)} — check Tailscale` + } + return `Cannot reach ${formatEndpoint(endpoint)}` +} diff --git a/mobile/src/diagnostics/troubleshoot-common-issues.tsx b/mobile/src/diagnostics/troubleshoot-common-issues.tsx new file mode 100644 index 000000000..5dc91eb65 --- /dev/null +++ b/mobile/src/diagnostics/troubleshoot-common-issues.tsx @@ -0,0 +1,73 @@ +import { WifiOff, Shield, Monitor, Clock, Globe } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' + +export type TroubleshootSection = { + id: string + icon: React.ReactNode + title: string + steps: string[] +} + +export const troubleshootCommonIssues: TroubleshootSection[] = [ + { + id: 'wifi', + icon: , + title: 'Different WiFi Networks', + steps: [ + 'Both devices must be on the same local network (unless connected through Tailscale).', + 'Ethernet and WiFi must share the same subnet.', + 'Try reconnecting WiFi on both devices.' + ] + }, + { + id: 'firewall', + icon: , + title: 'Firewall Blocking Port 6768', + steps: [ + 'macOS: System Settings → Network → Firewall — allow Orca.', + 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', + 'Linux: sudo ufw allow 6768', + 'Corporate/school networks may block P2P — try a personal hotspot.' + ] + }, + { + id: 'desktop', + icon: , + title: 'Desktop App Not Running', + steps: [ + 'Orca must be open on your desktop to accept connections.', + 'Try restarting Orca — the companion server starts on launch.', + 'After an update, you may need to re-pair via QR code.' + ] + }, + { + id: 'timeout', + icon: , + title: 'Connection Timeout', + steps: [ + 'Check WiFi signal strength on your phone.', + 'Go back to the host list and tap your host to retry.', + 'Restart both apps if timeouts persist.' + ] + }, + { + id: 'tailscale', + icon: , + title: 'Tailscale Host Unreachable', + steps: [ + 'Host addresses like 100.x.x.x or *.ts.net connect through Tailscale — keep it ON.', + 'iOS/Android can silently wedge the tunnel: toggle Tailscale off and back on in the Tailscale app.', + 'Check the desktop is awake and shows as connected in your tailnet.', + 'Update the Tailscale app — recent releases fix reconnect bugs.' + ] + }, + { + id: 'vpn', + icon: , + title: 'Other VPN Interference', + steps: [ + 'Non-Tailscale VPNs can route local traffic through a remote server.', + 'Disable that VPN or enable split tunneling / "Allow LAN".' + ] + } +] diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts new file mode 100644 index 000000000..22e752a63 --- /dev/null +++ b/mobile/src/transport/connection-health.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { classifyConnection, verdictDisplayLabel } from './connection-health' + +describe('classifyConnection Tailscale hint', () => { + const base = { + state: 'reconnecting' as const, + lastConnectedAt: null, + nowMs: 1_000_000 + } + + it('adds the hint to the warning verdict for a tailnet CGNAT endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://100.65.9.106:6768' + }) + expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' }) + }) + + it('adds the hint to the unreachable verdict for a MagicDNS endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 12, + endpoint: 'ws://my-desktop.tailnet-1234.ts.net:6768' + }) + expect(verdict).toMatchObject({ + kind: 'unreachable', + reason: 'never-connected', + hint: 'check Tailscale' + }) + }) + + it('keeps plain labels for LAN endpoints', () => { + const warning = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://192.168.1.50:6768' + }) + expect(warning.kind).toBe('warning') + expect('hint' in warning && warning.hint).toBeFalsy() + }) + + it('keeps plain labels when no endpoint is provided', () => { + const verdict = classifyConnection({ ...base, reconnectAttempts: 3 }) + expect(verdict.kind).toBe('warning') + expect('hint' in verdict && verdict.hint).toBeFalsy() + }) + + it('never hints on healthy states', () => { + const verdict = classifyConnection({ + state: 'connected', + reconnectAttempts: 0, + lastConnectedAt: 999_000, + endpoint: 'ws://100.65.9.106:6768', + nowMs: 1_000_000 + }) + expect(verdict).toEqual({ kind: 'normal', label: 'Connected' }) + }) +}) + +describe('verdictDisplayLabel', () => { + it('appends the hint to warning and unreachable labels', () => { + expect( + verdictDisplayLabel({ kind: 'warning', label: "Can't connect", hint: 'check Tailscale' }) + ).toBe("Can't connect — check Tailscale") + expect( + verdictDisplayLabel({ + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'stale', + hint: 'check Tailscale' + }) + ).toBe("Can't reach desktop — check Tailscale") + }) + + it('returns the bare label without a hint', () => { + expect(verdictDisplayLabel({ kind: 'warning', label: "Can't connect" })).toBe("Can't connect") + expect(verdictDisplayLabel({ kind: 'normal', label: 'Connected' })).toBe('Connected') + }) +}) diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 4ce81beff..d3b8251fc 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -1,3 +1,4 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' import type { ConnectionState } from './types' // Why: thresholds for escalating connection UX from neutral @@ -11,7 +12,8 @@ import type { ConnectionState } from './types' // reuse the 60s cap). Combined with the never-connected / // stale-since-last-connect heuristic below, this is the trigger to // surface a "re-pair?" affordance. MUST stay aligned with -// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS. +// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS (past which the loop slows +// to a 90s trickle instead of parking). // - STALE_SINCE_LAST_CONNECT_MS: 60s → if we WERE connected this // session but haven't been for ≥ 1 minute despite the retry loop // spinning, treat the same as never-connected. Catches the case @@ -20,10 +22,21 @@ const WARNING_ATTEMPTS = 3 const UNREACHABLE_ATTEMPTS = 12 const STALE_SINCE_LAST_CONNECT_MS = 60_000 +// Why: a repeatedly-unreachable 100.x/*.ts.net endpoint almost always means +// the phone's Tailscale tunnel is down or wedged (a known iOS failure mode +// that only a manual toggle fixes) — not that the desktop moved. Say so +// instead of leaving the user staring at a generic "Can't connect". +const TAILSCALE_HINT = 'check Tailscale' + export type ConnectionVerdict = | { kind: 'normal'; label: string } - | { kind: 'warning'; label: string } // "Can't connect" - | { kind: 'unreachable'; label: string; reason: 'never-connected' | 'stale' } + | { kind: 'warning'; label: string; hint?: string } // "Can't connect" + | { + kind: 'unreachable' + label: string + reason: 'never-connected' | 'stale' + hint?: string + } | { kind: 'auth-failed'; label: string } // Why: the rpc-client's lastConnectedAt is a one-shot timestamp; we have @@ -33,10 +46,14 @@ export function classifyConnection(args: { state: ConnectionState reconnectAttempts: number lastConnectedAt: number | null + // Optional pinned host endpoint — enables the Tailscale hint on + // warning/unreachable verdicts. Callers without it get plain labels. + endpoint?: string | null nowMs?: number }): ConnectionVerdict { const { state, reconnectAttempts, lastConnectedAt } = args const now = args.nowMs ?? Date.now() + const hint = isTailscaleEndpoint(args.endpoint) ? TAILSCALE_HINT : undefined if (state === 'auth-failed') { return { kind: 'auth-failed', label: 'Auth failed' } @@ -60,21 +77,32 @@ export function classifyConnection(args: { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'never-connected' + reason: 'never-connected', + hint } } if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'stale' + reason: 'stale', + hint } } } if (reconnectAttempts >= WARNING_ATTEMPTS) { - return { kind: 'warning', label: "Can't connect" } + return { kind: 'warning', label: "Can't connect", hint } } return { kind: 'normal', label: 'Reconnecting…' } } + +// Why: single place that turns a verdict into display text so every screen +// renders the Tailscale hint the same way. +export function verdictDisplayLabel(verdict: ConnectionVerdict): string { + if ((verdict.kind === 'warning' || verdict.kind === 'unreachable') && verdict.hint) { + return `${verdict.label} — ${verdict.hint}` + } + return verdict.label +} diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index e096ee659..b2adb18c4 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -618,28 +618,42 @@ describe('mobile rpc-client connection timeout', () => { return { client, socket } } - it('repro: a parked reconnect loop never retries on its own', async () => { + // Why 520_000ms: the 12 fast attempts cost Σ(RECONNECT_DELAYS) 360.5s of + // backoff plus 13 × 12s connect timeouts ≈ 516.5s, so 520s lands just + // past the give-up cap with the first trickle timer armed. + const PAST_GIVE_UP_CAP_MS = 520_000 + + it('keeps trickle-retrying after the give-up cap instead of parking', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getState()).toBe('reconnecting') - expect(client.getReconnectAttempt()).toBe(12) + expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(12) - // Stuck: arbitrary additional time produces no further attempts. + // A wedged VPN produces no revival nudge (issue #7824) — the loop must + // keep dialing on its own at the 90s trickle cadence. const socketsBefore = mockSockets.length - await vi.advanceTimersByTimeAsync(600_000) - expect(mockSockets.length).toBe(socketsBefore) + await vi.advanceTimersByTimeAsync(102_000) + expect(mockSockets.length).toBeGreaterThan(socketsBefore) + + // Once the tunnel heals, a trickle dial restores the session without + // any user action. 75s lands inside the next dial's 12s connect window + // (the prior dial failed mid-advance above, re-arming the 90s timer). + await vi.advanceTimersByTimeAsync(75_000) + openAndAuthenticate(mockSockets[mockSockets.length - 1]!) + expect(client.getState()).toBe('connected') + expect(client.getReconnectAttempt()).toBe(0) client.close() }) - it('restarts a parked reconnect loop on foreground', async () => { + it('restarts a backed-off reconnect loop on foreground without waiting out the trickle', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getReconnectAttempt()).toBe(12) const socketsBefore = mockSockets.length @@ -921,7 +935,7 @@ describe('mobile rpc-client connection timeout', () => { () => null, (error: Error) => error ) - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(520_000) expect(client.getState()).toBe('reconnecting') expect(client.getReconnectAttempt()).toBe(12) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 2cd484de4..b9d0c9902 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -103,16 +103,22 @@ export type RpcClient = { // time across all 12 attempts is ≈ 6 minutes before the give-up cap // fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s). const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000] -// Why: cap auto-retry once we're clearly unreachable for a long time. +// Why: cap fast auto-retry once we're clearly unreachable for a long time. // With the tiered backoff above this is ≈ 6 minutes of continuous -// failure before we stop and surface the re-pair banner. The longer +// failure before the UI surfaces the re-pair banner. The longer // runway tolerates flaky AP-isolation routers and laptop sleep cycles // that briefly drop the LAN path. MUST stay aligned with // connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable" -// verdict matches the moment the loop actually pauses — if these -// drift the user sees "Reconnecting…" while the loop is silently -// parked. +// verdict matches the moment the loop slows to the trickle cadence. const GIVE_UP_AFTER_ATTEMPTS = 12 +// Why: past the cap the loop must never park permanently. A wedged +// Tailscale/VPN tunnel produces no AppState or network-type transition +// (still Wi-Fi, still "online"), so no revival nudge ever fires — users +// had to toggle Tailscale off/on just to force one. A slow trickle dial +// self-heals once the tunnel recovers while staying cheap: one TCP +// attempt per 90s, foreground-only (iOS/Android suspend JS timers in +// the background). +const TRICKLE_RECONNECT_DELAY_MS = 90_000 // Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead. // Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair // while the desktop still listed it as paired with a valid token — a transient @@ -280,9 +286,11 @@ export function connect( if (intentionallyClosed) { return Promise.reject(new Error('Client closed')) } - if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS && !reconnectTimer) { - // Why: after the retry cap there is no future state transition to - // release callers waiting before their per-request timeout starts. + if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { + // Why: past the retry cap the loop only trickles every 90s — callers + // must fail fast rather than hang on a host that's been unreachable + // for minutes. A trickle dial that succeeds flips state to 'connected' + // and later requests go through normally. return Promise.reject(new Error('Connection retry limit reached')) } return new Promise((resolve, reject) => { @@ -773,27 +781,35 @@ export function connect( } function scheduleReconnect() { - // Why: spinning reconnect forever drains battery and floods logs + // Why: spinning fast reconnects forever drains battery and floods logs // when the host is genuinely unreachable (wrong IP, port closed, - // host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a - // "Can't reach desktop, re-pair?" banner at this point and the - // user can tap Retry (forceReconnect creates a fresh client, - // resetting the counter) or Re-pair. Without an explicit cap the - // worst-case is a phone left on the home screen burning a socket - // open every 4s indefinitely. - if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { - console.log('[net] reconnect-paused', { - attempt: reconnectAttempt, - reason: 'give-up-cap', - endpoint: redactedEndpoint(endpoint) - }) + // host moved). Past GIVE_UP_AFTER_ATTEMPTS the UI surfaces a + // "Can't reach desktop, re-pair?" banner and the loop drops to the + // 90s trickle cadence instead of parking — a permanently parked loop + // could only be revived by an AppState/network transition, which a + // wedged VPN tunnel never produces. + const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS + let delay: number + if (pastGiveUpCap) { + // Why: the counter holds at the cap — connection-health thresholds and + // the "Can't reach desktop" verdict key off attempts >= 12, and a + // successful open resets it to 0 anyway. + delay = TRICKLE_RECONNECT_DELAY_MS rejectConnectWaiters('Connection retry limit reached') - return + } else { + delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! + reconnectAttempt++ } - const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! - reconnectAttempt++ - console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt }) - emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`) + console.log('[net] scheduleReconnect', { + delayMs: delay, + attempt: reconnectAttempt, + trickle: pastGiveUpCap + }) + emitLog( + 'info', + `Reconnect scheduled in ${delay}ms`, + pastGiveUpCap ? `Attempt ${reconnectAttempt} (slow retry)` : `Attempt ${reconnectAttempt}` + ) reconnectTimer = setTimeout(() => { reconnectTimer = null openConnection() @@ -1176,10 +1192,10 @@ export function connect( return } if (state === 'reconnecting') { - // Why: while backgrounded the retry loop may have parked at the - // give-up cap or be sitting on a 60s backoff timer. Returning to - // the foreground is a strong user signal — restart with a fresh - // attempt budget immediately instead of requiring an app restart. + // Why: while backgrounded the retry loop may be sitting on a 60s + // backoff or 90s trickle timer. Returning to the foreground is a + // strong user signal — restart with a fresh attempt budget + // immediately instead of waiting out the timer. console.log('[net] foreground — restarting reconnect loop', { attempt: reconnectAttempt, hadTimer: !!reconnectTimer