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