Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions Sources/VPNBypassCore/RerouteDecider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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
}
}
58 changes: 51 additions & 7 deletions Sources/VPNBypassCore/RouteManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/// 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] = [:]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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() }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return await HelperManager.shared.removeRoutesBatch(destinations: destinations)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -3272,7 +3310,7 @@ final class RouteManager: ObservableObject {
// Attempt kernel removal first
var failedKernelRemovals: Set<String> = []
if !kernelRemovals.isEmpty {
let result = await HelperManager.shared.removeRoutesBatch(destinations: kernelRemovals)
let result = await removeRoutesBatchVia(kernelRemovals)
failedKernelRemovals = Set(result.failedDestinations)
}

Expand Down Expand Up @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions Tests/VPNBypassTests/ReconnectSettleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}