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:
parent
e537953d8f
commit
73f1bbfc94
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` : ''}`
|
||||
: ''}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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".'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue