mobile: recover from wedged Tailscale tunnels and say 'check Tailscale' when it's the likely culprit (#7980)

A wedged Tailscale tunnel (known iOS failure mode) produces no AppState
or network-type transition, so no revival nudge ever fires and the
reconnect loop parked permanently at its give-up cap — users had to
toggle Tailscale off/on just to force a transition (#7824).

- rpc-client: past the give-up cap, drop to a 90s trickle dial instead
  of parking so the session self-heals once the tunnel recovers.
- host screen: nudge the shared client on focus so opening the host
  retries immediately instead of waiting out a backoff/trickle timer.
- connection-health: warning/unreachable verdicts on 100.64/10 or
  *.ts.net endpoints now carry a 'check Tailscale' hint, shown on the
  home host list and the in-session status line after ~3 failed
  attempts.
- troubleshoot: 'Cannot reach <tailnet-ip>' now says to check
  Tailscale, adds a dedicated Tailscale section, and stops telling
  Tailscale users to disable their VPN (that advice killed their only
  route to the host); sections extracted to
  troubleshoot-common-issues.tsx to stay under the max-lines cap.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-09 15:56:25 -07:00 committed by GitHub
parent e537953d8f
commit 73f1bbfc94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 328 additions and 123 deletions

View File

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

View File

@ -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<Map<string, string>>(new Map())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(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<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
const connStateRef = useRef<ConnectionState>(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

View File

@ -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() {
<View style={styles.hostMeta}>
<StatusDot state={state} verdict={verdict} />
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
{verdict.label}
{verdictDisplayLabel(verdict)}
{connected && info
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
: ''}

View File

@ -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: <WifiOff size={16} color={colors.textSecondary} />,
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: <Shield size={16} color={colors.textSecondary} />,
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: <Monitor size={16} color={colors.textSecondary} />,
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: <Clock size={16} color={colors.textSecondary} />,
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: <Globe size={16} color={colors.textSecondary} />,
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() {
<Text style={styles.sectionHeading}>Common issues</Text>
<View style={styles.section}>
{sections.map((section, i) => (
{troubleshootCommonIssues.map((section, i) => (
<View key={section.id}>
{i > 0 && <View style={styles.separator} />}
<Pressable

View File

@ -1,5 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
import { testHostReachability } from './host-reachability'
import { testHostReachability, unreachableHostDetail } from './host-reachability'
describe('unreachableHostDetail', () => {
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 () => {

View File

@ -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)}`
}

View File

@ -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: <WifiOff size={16} color={colors.textSecondary} />,
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: <Shield size={16} color={colors.textSecondary} />,
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: <Monitor size={16} color={colors.textSecondary} />,
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: <Clock size={16} color={colors.textSecondary} />,
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: <Globe size={16} color={colors.textSecondary} />,
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: <Shield size={16} color={colors.textSecondary} />,
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".'
]
}
]

View File

@ -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')
})
})

View File

@ -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
}

View File

@ -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)

View File

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