Address code review findings on the reconnect fallback:
- Fix (High): a forced reconnect is disconnect { connect }, so its
intermediate .disconnected surfaced with previousStatus == .connecting
while isReconnecting was set. The clean-disconnect and generic-error
branches lacked the isReconnecting guard the connectivity branch has,
so giveUp() could fire on the reconnect's own teardown and terminate
the 20/40/60 backoff after the first attempt. Extract the decision into
a pure TerminalDisconnectPolicy and gate both branches on !isReconnecting.
- Fix (Medium): giveUp() had no timeout; if the disconnect completion or
the .disconnected event never arrived, isGivingUp stayed true forever
and silently killed every future connect. Add a watchdog that force-
resets the give-up state after vpnDisconnectWaitTimeout.
- Add unit coverage for TerminalDisconnectPolicy, including the
reconnect-teardown regression case.
Here's the full picture of everything this branch does, in plain language.
The problem it fixes
The original bug was a "reconnect storm": when connecting the VPN (especially WireGuard on slower/older devices), the app could get stuck rapidly retrying — killing connections that were just slow, hammering servers, and sometimes freezing in "Connecting…" or a stuck state. Along the way, a few related failure modes were fixed too.
What changed, area by area
1. More patient timing (
Client+Configuration.swift)2. Bounded retries instead of an endless loop (
VPNDaemon.swift)3. Never abandon a connection you actually had — the key safety behavior
didReachConnected) that's set once you reach Connected and cleared only when you manually disconnect or it truly gives up.VPNFallbackPolicy.decision): no internet → give up (nothing to leak, avoids the kill-switch deadlock); internet + you were connected → keep trying; internet + cold connect → bounded by the cap.4. Clean "stop, wait, then start" handoff (
NetworkExtensionProfile.swift+ the WireGuard/OpenVPN/IKEv2 profiles)TunnelRestartCoordinatorwith one rule: only start a new tunnel once the old one is fully shut down. Otherwise stop it, wait for it to settle, then start.vpnDisconnectTimedOut, inClientError.swift) instead of stacking a new tunnel on a dying one. Same safe logic now for all three protocols.5. A real disconnect when giving up, with a safety net (
VPNDaemon.swift)6. The "stuck in Connecting forever" fix (found in device testing)
Code-quality cleanup (no behavior change)
handleDisconnectError(classifies the disconnect reason),isConnectivityCheckFailure(the WireGuard/OpenVPN/IKEv2 error-matching), andstartReconnect(dedupes the two identical reconnect blocks).VPNFallbackPolicy,VPNRetryDecision,TerminalDisconnectPolicy,VPNGiveUpState,TunnelRestartPolicy).// MARK: -section separators throughout for navigation.VPNFallbackPolicyTests.swift) cover the backoff math, the retry/give-up decision (including "keep trying forever while connected" and "cold connect is bounded"), the restart-safety rule, the give-up state machine, and the reconnect-teardown guard.Bottom line
A connection attempt gets a realistic amount of time; a cold connect that can't succeed stops after 3 tries; a connection that drops while you still have internet keeps recovering indefinitely and never leaves you unprotected; server switches never stack tunnels; give-up can't hang the app; and the watchdog now reliably rotates servers so it can't sit in "Connecting…" forever.
One caveat worth remembering: during a sustained outage the retry cadence settles at ~60s between attempts (kill switch protects you throughout) — snappier than the old storm, but a genuinely-desired reconnect could take up to a minute once a good server is reachable. That's a tuning knob if you want it faster.