From 29eb18386149364159d5253a2ab8f377afea032e Mon Sep 17 00:00:00 2001 From: umarfatmi05 Date: Fri, 4 Sep 2026 19:13:29 -0400 Subject: [PATCH] Trust surviving tunnels, skip doomed cellular reconnects, log hold health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../LocationSimulationKeepAlive.swift | 36 ++++++++++++++++- StikDebug/Services/TunnelManager.swift | 40 +++++++++++++------ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/StikDebug/Services/LocationSimulationKeepAlive.swift b/StikDebug/Services/LocationSimulationKeepAlive.swift index ccb1038..65b08ad 100644 --- a/StikDebug/Services/LocationSimulationKeepAlive.swift +++ b/StikDebug/Services/LocationSimulationKeepAlive.swift @@ -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" + ) } } diff --git a/StikDebug/Services/TunnelManager.swift b/StikDebug/Services/TunnelManager.swift index afec7b0..c13ffcb 100644 --- a/StikDebug/Services/TunnelManager.swift +++ b/StikDebug/Services/TunnelManager.swift @@ -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 } - LogManager.shared.addInfoLog( - "Interface changed (\(previous) → \(status.interfaceName)); rebuilding tunnel" - ) - markDisconnected() + // 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( + "On cellular without Wi-Fi; deferring tunnel reconnect until a local network is available" + ) + } + return + } + + LogManager.shared.addInfoLog("Network path changed (\(status.description)); reconnecting tunnel") start(showErrorUI: false) }