Trust surviving tunnels, skip doomed cellular reconnects, log hold health

Field testing on device contradicted the assumption behind the interface-change
rebuild: connections established over LocalDevVPN's loopback SURVIVE a
Wi-Fi<->cellular handoff (an active location-sim session kept working across
the switch). Tearing the tunnel state down on interface change therefore
destroyed a working tunnel and, on cellular — where a fresh connect is refused
because nothing listens on port 49152 without a local network interface —
replaced it with a failure. Keep connected tunnels alone.

For the same reason, skip the automatic reconnect cycle when the path is
cellular-only: three retries with backoff that are refused every time, on
every path flap, forever. Manual attempts still go through start() directly.

Also count resend results in LocationSimulationKeepAlive and log transitions —
first failure, recovery, and a heartbeat every ~5 minutes while failing — so
the Logs tab shows whether a held location is actually being applied instead
of failing silently. Counter resets on hold/release so stale counts can't
fake a recovery message.

Not verified on device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
umarfatmi05 2026-09-04 19:13:29 -04:00
parent ef78da3c05
commit 29eb183861
2 changed files with 61 additions and 15 deletions

View file

@ -39,6 +39,7 @@ final class LocationSimulationKeepAlive {
private var coordinate: CLLocationCoordinate2D?
private var timer: DispatchSourceTimer?
private var isHolding = false
private var consecutiveResendFailures = 0
/// Main-thread only, mirroring `DebugKeepAliveLease`.
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
@ -65,6 +66,9 @@ final class LocationSimulationKeepAlive {
stateLock.lock()
let isFirstHold = coordinate == nil
coordinate = newCoordinate
if isFirstHold {
consecutiveResendFailures = 0
}
stateLock.unlock()
persist(newCoordinate)
@ -126,6 +130,7 @@ final class LocationSimulationKeepAlive {
coordinate = nil
timer?.cancel()
timer = nil
consecutiveResendFailures = 0
stateLock.unlock()
persist(nil)
@ -170,13 +175,40 @@ final class LocationSimulationKeepAlive {
// set fails, so a dropped tunnel (e.g. a Wi-Fi<->cellular handoff) heals
// on the next tick. A failure here is expected and transient; the next
// resend retries.
LocationSimulationCommandQueue.shared.async {
_ = simulate_location(
LocationSimulationCommandQueue.shared.async { [weak self] in
let code = simulate_location(
DeviceConnectionContext.targetIPAddress,
target.latitude,
target.longitude,
PairingFileStore.prepareURL().path
)
self?.noteResendResult(code)
}
}
/// Surfaces hold health in the log without spamming it: the first failure,
/// the recovery, and a heartbeat roughly every five minutes while failing.
private func noteResendResult(_ code: Int32) {
stateLock.lock()
let previousFailures = consecutiveResendFailures
consecutiveResendFailures = code == 0 ? 0 : previousFailures + 1
let failures = consecutiveResendFailures
stateLock.unlock()
if code == 0 {
if previousFailures > 0 {
LogManager.shared.addInfoLog(
"Simulated location resend recovered after \(previousFailures) failed attempt(s)"
)
}
} else if failures == 1 {
LogManager.shared.addWarningLog(
"Simulated location resend failed (error \(code)); retrying every \(Int(Self.resendInterval))s"
)
} else if failures % 75 == 0 {
LogManager.shared.addWarningLog(
"Simulated location resend still failing after \(failures) attempts (error \(code)) — the held location is NOT being applied"
)
}
}

View file

@ -127,23 +127,37 @@ final class TunnelManager: ObservableObject {
let previous = lastInterfaceName
lastInterfaceName = status.interfaceName
let interfaceChanged = previous != nil && previous != status.interfaceName
if !isConnected {
LogManager.shared.addInfoLog("Network path changed (\(status.description)); connecting tunnel")
start(showErrorUI: false)
// A tunnel established earlier survives a Wi-Fi↔cellular handoff: the
// connection rides LocalDevVPN's loopback, so the physical interface
// underneath it doesn't matter (verified on device — an active session
// kept working across the switch). Rebuilding here would tear down a
// live tunnel and, on cellular, fail to replace it.
guard !isConnected else {
if interfaceChanged {
LogManager.shared.addInfoLog(
"Interface changed (\(previous ?? "?") → \(status.interfaceName)); keeping existing tunnel"
)
}
return
}
// isConnected only means "the last connect succeeded" — nothing in the app
// notices a tunnel that died quietly, which is exactly what a handoff does.
// So don't trust it: if the physical interface actually changed, treat the
// old tunnel as dead and rebuild. startTunnel() is semaphore-guarded and
// recreates its adapter, so reconnecting a live tunnel is safe.
guard let previous, previous != status.interfaceName else { return }
// A fresh connect needs the on-device service listening on port 49152,
// and iOS only binds it when a real local network interface exists.
// Cellular-only refuses the connect outright, so an automatic retry
// cycle just burns battery. Manual attempts (the user tapping) still go
// through start() directly.
guard status.interface != .cellular else {
if interfaceChanged {
LogManager.shared.addInfoLog(
"Interface changed (\(previous) → \(status.interfaceName)); rebuilding tunnel"
"On cellular without Wi-Fi; deferring tunnel reconnect until a local network is available"
)
markDisconnected()
}
return
}
LogManager.shared.addInfoLog("Network path changed (\(status.description)); reconnecting tunnel")
start(showErrorUI: false)
}