diff --git a/Sources/VPNBypassCore/RerouteDecider.swift b/Sources/VPNBypassCore/RerouteDecider.swift index bc692f9..93b8513 100644 --- a/Sources/VPNBypassCore/RerouteDecider.swift +++ b/Sources/VPNBypassCore/RerouteDecider.swift @@ -82,14 +82,64 @@ enum RerouteDecider { /// start (nothing installed, the user is waiting for their bypasses) waits less; and once a /// flap storm has cancelled the wait `maxDeferrals` times, the delay collapses so the apply /// can never be starved forever — by then the paced helper writes are the remaining shield. +/// +/// Kill strikes are the second, opposing signal. The deferral collapse assumes the apply is +/// the victim of the flapping; a kill strike is evidence the apply is the CAUSE: the VPN +/// dropped within `killWindow` of our own kernel-write burst. One observed GlobalProtect +/// session died that way 28 times in 51 minutes — restore → settle wait → apply → its +/// gateway-route read starves on our route-change broadcasts → drop → restore re-arms the +/// next apply — until the client's retry gave up and logged the user out of the gateway +/// entirely (an on-demand session never reconnects from that on its own). Every drop in +/// that loop landed AFTER the apply had already fired, so `deferrals` stayed at zero and +/// the collapse would only have poked the client faster. When strikes are on the board, +/// starving the apply is the point: the delay grows exponentially per strike instead, and +/// the deferral collapse is ignored. Bypass-mode routes egress the local gateway and stay +/// correct while we wait; the cap stays modest because a VPN-Only reconnect has no routes +/// installed until the apply runs, so its forced-via-VPN destinations egress direct in the +/// meantime — a bounded wait, not an unbounded one. enum ReconnectSettle { static let warmDelay: TimeInterval = 20 static let coldDelay: TimeInterval = 10 static let cappedDelay: TimeInterval = 5 static let maxDeferrals = 5 + /// A drop this soon after our last kernel-write burst is attributed to the burst. Wide + /// enough to cover the observed chain — batch → client's route read times out (≤16s) → + /// teardown → our status timer notices (up to ~30s + the 1.5s disconnect recheck). + static let killWindow: TimeInterval = 90 + /// Strikes older than this stop shaping the delay: the flap is no longer hot, so the + /// next reconnect starts from the normal settle window again (the way OUT of backoff). + static let strikeExpiry: TimeInterval = 1800 + /// Ceiling for the backed-off delay. Long enough to give a struggling client calm + /// multi-minute windows (the observed loop cycled every 2–3 minutes), short enough to + /// bound how long a VPN-Only reconnect runs without its forced-via-VPN routes. + static let maxBackoffDelay: TimeInterval = 240 - static func delay(deferrals: Int, hasInstalledRoutes: Bool) -> TimeInterval { + static func delay(deferrals: Int, hasInstalledRoutes: Bool, killStrikes: Int = 0) -> TimeInterval { + let base = hasInstalledRoutes ? warmDelay : coldDelay + if killStrikes > 0 { + // We are the suspected killer — the anti-starvation collapse must not apply. + // min() guards pow against absurd strike counts; the cap lands first anyway. + return min(base * pow(2.0, Double(min(killStrikes, 8))), maxBackoffDelay) + } if deferrals >= maxDeferrals { return cappedDelay } - return hasInstalledRoutes ? warmDelay : coldDelay + return base + } + + /// Was this detected drop plausibly caused by our own kernel writes? `dropAt` earlier + /// than the burst (clock adjustment, reordered detection) is NOT a kill — attribution + /// only ever looks forward. + static func isSuspectedApplyKill(dropAt: Date, lastBurstAt: Date?) -> Bool { + guard let burstAt = lastBurstAt else { return false } + let sinceBurst = dropAt.timeIntervalSince(burstAt) + return sinceBurst >= 0 && sinceBurst < killWindow + } + + /// Strikes decay as a whole once the LAST one is `strikeExpiry` old — an unrelated drop + /// in between neither adds nor clears (a wedged client dropping on its own mid-storm + /// must not reset the backoff and re-enable the resonance). + static func effectiveStrikes(_ strikes: Int, lastStrikeAt: Date?, now: Date) -> Int { + guard strikes > 0, let last = lastStrikeAt, + now.timeIntervalSince(last) < strikeExpiry else { return 0 } + return strikes } } diff --git a/Sources/VPNBypassCore/RouteManager.swift b/Sources/VPNBypassCore/RouteManager.swift index 2932a81..ef65df2 100644 --- a/Sources/VPNBypassCore/RouteManager.swift +++ b/Sources/VPNBypassCore/RouteManager.swift @@ -87,9 +87,18 @@ final class RouteManager: ObservableObject { /// Bypass routes are KEPT across a drop, so there is no urgency to re-apply: wait for the /// tunnel to hold before touching the kernel. A drop during the wait cancels the pending /// apply and counts a deferral, so a flap storm coalesces into ONE apply once things hold; - /// after `ReconnectSettle.maxDeferrals` the delay shortens so we can never starve. + /// after `ReconnectSettle.maxDeferrals` the delay shortens so we can never starve — unless + /// apply-kill strikes are on the board (below), which override the collapse on purpose. private var reconnectSettleTask: Task? private var reconnectDeferrals = 0 + /// Apply-kill backoff (the resonance breaker). `lastKernelBurstAt` is stamped by the two + /// batch funnels every kernel write goes through; a VPN drop detected within + /// `ReconnectSettle.killWindow` of it counts a strike, and each strike doubles the next + /// settle delay. Deferrals count drops DURING the wait (apply is the victim); strikes + /// count drops AFTER the apply (apply is the suspected cause) — opposite responses. + private var applyKillStrikes = 0 + private var lastApplyKillAt: Date? + private var lastKernelBurstAt: Date? /// First-seen timestamps for routes that dropped out of the desired set during an /// auto-triggered apply (DNS rotation, mostly). See `orphanGraceDecision`. private var orphanFirstSeen: [String: Date] = [:] @@ -709,9 +718,16 @@ final class RouteManager: ObservableObject { // Settle gate: never fire the apply into the tunnel's fragile post-(re)connect // window — see reconnectSettleTask. The notification still goes out now (the // VPN *is* connected); only our kernel writes wait. + applyKillStrikes = ReconnectSettle.effectiveStrikes(applyKillStrikes, + lastStrikeAt: lastApplyKillAt, + now: Date()) let delay = ReconnectSettle.delay(deferrals: reconnectDeferrals, - hasInstalledRoutes: !activeRoutes.isEmpty) - log(.success, "VPN connected via \(interface ?? "unknown") (\(detectedType?.rawValue ?? "unknown type")) — applying routes in \(Int(delay))s once the tunnel settles") + hasInstalledRoutes: !activeRoutes.isEmpty, + killStrikes: applyKillStrikes) + let backoffNote = applyKillStrikes > 0 + ? " (backed off — \(applyKillStrikes) suspected apply-kill\(applyKillStrikes == 1 ? "" : "s"))" + : "" + log(.success, "VPN connected via \(interface ?? "unknown") (\(detectedType?.rawValue ?? "unknown type")) — applying routes in \(Int(delay))s once the tunnel settles\(backoffNote)") NotificationManager.shared.notifyVPNConnected(interface: interface ?? "unknown") reconnectSettleTask?.cancel() reconnectSettleTask = Task { [weak self] in @@ -802,6 +818,22 @@ final class RouteManager: ObservableObject { if !isVPNConnected && wasVPNConnected { log(.warning, "VPN disconnected (was: \(oldInterface ?? "unknown"))") + // Apply-kill attribution: a drop this soon after our own kernel-write burst means + // the burst most likely starved the VPN client's gateway-route read (the #65 + // signature). Count a strike so the NEXT post-reconnect apply waits exponentially + // longer — without this, restore → apply → drop → restore re-arms the apply and + // the loop self-sustains until the client gives up and logs out of its gateway. + // The burst is CONSUMED by its strike (one strike per batch): a reconnect that + // drops again during the settle wait fired no new writes, so blaming the same + // burst twice would escalate the delay for a single incident. Drops with no + // live burst stay attributed to the outside world. + if let burstAt = lastKernelBurstAt, + ReconnectSettle.isSuspectedApplyKill(dropAt: Date(), lastBurstAt: burstAt) { + applyKillStrikes += 1 + lastApplyKillAt = Date() + lastKernelBurstAt = nil + log(.warning, "VPN dropped \(Int(Date().timeIntervalSince(burstAt)))s after our last route write — suspected apply-kill (strike \(applyKillStrikes)); backing off the next post-reconnect apply") + } // The tunnel dropped during a settle wait: cancel the pending apply (routes are // kept, nothing is lost) and count the deferral so a long flap storm eventually // shortens the wait instead of starving forever. @@ -2293,11 +2325,11 @@ final class RouteManager: ObservableObject { let catchAlls = destinations.filter { RouteCompiler.catchAllDestinations.contains($0) } let rest = destinations.filter { !RouteCompiler.catchAllDestinations.contains($0) } if !catchAlls.isEmpty { - let r = await HelperManager.shared.removeRoutesBatch(destinations: catchAlls) + let r = await removeRoutesBatchVia(catchAlls) failedDests.formUnion(r.failedDestinations) } if !rest.isEmpty { - let result = await HelperManager.shared.removeRoutesBatch(destinations: rest) + let result = await removeRoutesBatchVia(rest) failedDests.formUnion(result.failedDestinations) if result.failureCount > 0 { log(.warning, "Batch route removal: \(result.successCount) succeeded, \(result.failureCount) failed — retaining failed entries in model") @@ -2503,6 +2535,12 @@ final class RouteManager: ObservableObject { /// HelperManager.removeRoutesBatch. private func removeRoutesBatchVia(_ destinations: [String]) async -> (successCount: Int, failureCount: Int, failedDestinations: [String], error: String?) { if let override = removeRoutesBatchOverrideForTests { return await override(destinations) } + // Apply-kill attribution anchor (see addRoutesBatchTracked): deletes are broadcast to + // every open routing socket just like adds. Stamped at SUBMISSION, not completion — a + // status check can interleave at the await below, and a drop caused by the in-flight + // batch must not be judged against a pre-batch timestamp. Not stamped on the test + // override — that path writes nothing to the kernel. + if !destinations.isEmpty { lastKernelBurstAt = Date() } return await HelperManager.shared.removeRoutesBatch(destinations: destinations) } @@ -2860,7 +2898,7 @@ final class RouteManager: ObservableObject { } if !kernelRemovalDests.isEmpty { - let result = await HelperManager.shared.removeRoutesBatch(destinations: kernelRemovalDests) + let result = await removeRoutesBatchVia(kernelRemovalDests) if result.failureCount > 0 { // Re-add activeRoute entries for destinations that failed kernel removal let failedSet = Set(result.failedDestinations) @@ -3272,7 +3310,7 @@ final class RouteManager: ObservableObject { // Attempt kernel removal first var failedKernelRemovals: Set = [] if !kernelRemovals.isEmpty { - let result = await HelperManager.shared.removeRoutesBatch(destinations: kernelRemovals) + let result = await removeRoutesBatchVia(kernelRemovals) failedKernelRemovals = Set(result.failedDestinations) } @@ -4453,6 +4491,12 @@ final class RouteManager: ObservableObject { } } pendingKernelAdds.formUnion(routes.map { $0.destination }) + // Apply-kill attribution anchor: every add path funnels through here, and even a + // failed RTM write is broadcast to every open routing socket, so a submitted batch + // is a burst whether or not it stuck. Stamped at SUBMISSION — a status check can + // interleave at the await below, and a drop caused by the in-flight batch must not + // be judged against a pre-batch timestamp. + if !routes.isEmpty { lastKernelBurstAt = Date() } let result = await HelperManager.shared.addRoutesBatch(routes: routes) // Failed destinations never reached the kernel — nothing to sweep for them. pendingKernelAdds.subtract(result.failedDestinations) diff --git a/Tests/VPNBypassTests/ReconnectSettleTests.swift b/Tests/VPNBypassTests/ReconnectSettleTests.swift index b6a2fcc..ac6720d 100644 --- a/Tests/VPNBypassTests/ReconnectSettleTests.swift +++ b/Tests/VPNBypassTests/ReconnectSettleTests.swift @@ -41,4 +41,78 @@ final class ReconnectSettleTests: XCTestCase { } XCTAssertLessThan(ReconnectSettle.cappedDelay, ReconnectSettle.coldDelay) } + + // MARK: - Apply-kill backoff (the resonance breaker) + + /// Each strike doubles the wait from the warm/cold base, then the ceiling holds. This is + /// what turns a 2–3-minute kill cycle into multi-minute calm windows for the VPN client. + func testKillStrikesGrowDelayExponentiallyAndCap() { + XCTAssertEqual(ReconnectSettle.delay(deferrals: 0, hasInstalledRoutes: true, killStrikes: 1), + ReconnectSettle.warmDelay * 2) + XCTAssertEqual(ReconnectSettle.delay(deferrals: 0, hasInstalledRoutes: true, killStrikes: 2), + ReconnectSettle.warmDelay * 4) + XCTAssertEqual(ReconnectSettle.delay(deferrals: 0, hasInstalledRoutes: true, killStrikes: 3), + ReconnectSettle.warmDelay * 8) + for strikes in 4...50 { + XCTAssertEqual(ReconnectSettle.delay(deferrals: 0, hasInstalledRoutes: true, + killStrikes: strikes), + ReconnectSettle.maxBackoffDelay) + } + } + + /// The regression test for the observed logout loop: every drop landed AFTER the apply, + /// so deferrals stayed at zero — but had they climbed, the anti-starvation collapse would + /// have fired the apply FASTER into an already-dying tunnel. With strikes on the board, + /// backoff must win over the collapse: when we are the suspected killer, starving the + /// apply is the point. + func testKillStrikesOverrideDeferralCollapse() { + let delay = ReconnectSettle.delay(deferrals: ReconnectSettle.maxDeferrals, + hasInstalledRoutes: true, + killStrikes: 2) + XCTAssertEqual(delay, ReconnectSettle.warmDelay * 4) + XCTAssertGreaterThan(delay, ReconnectSettle.cappedDelay) + } + + /// A cold start backs off from its own (shorter) base — the user is waiting, but a dead + /// VPN serves them worse than a late apply. + func testKillStrikesFromColdBase() { + XCTAssertEqual(ReconnectSettle.delay(deferrals: 0, hasInstalledRoutes: false, killStrikes: 1), + ReconnectSettle.coldDelay * 2) + } + + /// killStrikes defaults to 0, so every pre-existing call keeps its exact meaning. + func testZeroStrikesKeepLegacyBehavior() { + XCTAssertEqual(ReconnectSettle.delay(deferrals: 0, hasInstalledRoutes: true, killStrikes: 0), + ReconnectSettle.delay(deferrals: 0, hasInstalledRoutes: true)) + XCTAssertEqual(ReconnectSettle.delay(deferrals: ReconnectSettle.maxDeferrals, + hasInstalledRoutes: true, killStrikes: 0), + ReconnectSettle.cappedDelay) + } + + /// Attribution only ever looks forward from the burst, within the window: a drop inside + /// it is ours to answer for; outside it, before it (clock adjustment), or with no burst + /// recorded at all, the drop is external. + func testSuspectedApplyKillWindow() { + let burst = Date() + XCTAssertTrue(ReconnectSettle.isSuspectedApplyKill( + dropAt: burst.addingTimeInterval(ReconnectSettle.killWindow - 1), lastBurstAt: burst)) + XCTAssertFalse(ReconnectSettle.isSuspectedApplyKill( + dropAt: burst.addingTimeInterval(ReconnectSettle.killWindow), lastBurstAt: burst)) + XCTAssertFalse(ReconnectSettle.isSuspectedApplyKill( + dropAt: burst.addingTimeInterval(-1), lastBurstAt: burst)) + XCTAssertFalse(ReconnectSettle.isSuspectedApplyKill(dropAt: burst, lastBurstAt: nil)) + } + + /// The way OUT of backoff is time, not an unrelated drop: strikes hold while the flap is + /// hot (a wedged client dropping on its own in between must not reset the backoff and + /// re-enable the resonance) and expire as a whole once the last strike is old. + func testEffectiveStrikesDecay() { + let now = Date() + let hot = now.addingTimeInterval(-(ReconnectSettle.strikeExpiry - 60)) + let cold = now.addingTimeInterval(-(ReconnectSettle.strikeExpiry + 60)) + XCTAssertEqual(ReconnectSettle.effectiveStrikes(3, lastStrikeAt: hot, now: now), 3) + XCTAssertEqual(ReconnectSettle.effectiveStrikes(3, lastStrikeAt: cold, now: now), 0) + XCTAssertEqual(ReconnectSettle.effectiveStrikes(0, lastStrikeAt: hot, now: now), 0) + XCTAssertEqual(ReconnectSettle.effectiveStrikes(3, lastStrikeAt: nil, now: now), 0) + } }