Skip to content
Open
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
15 changes: 14 additions & 1 deletion FptnVPN/Cpp/Wrappers/WebScoketClientBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ final class WebsocketClientBridge {
private let disconnectedCallback: DisconnectionCallback
private let ipAssignedCallback: IPAssignedCallback

// PR2: one-shot lifecycle latch.
private enum Lifecycle { case created, started, stopped }
private let lifecycleLock = NSLock()
private var lifecycle: Lifecycle = .created

// MARK: - Init / deinit

init(
Expand Down Expand Up @@ -125,16 +130,24 @@ final class WebsocketClientBridge {

// MARK: - Control

// PR2: lifecycle-locked start/stop.
@discardableResult
func start() -> Bool {
lifecycleLock.lock()
defer { lifecycleLock.unlock() }
guard lifecycle == .created else { return false }
let ok = clientBridge.start()
lifecycle = ok ? .started : .stopped
logger.debug("WebSocket start → \(ok)")
return ok
}

// PR1C: stop accepts an origin for disconnect classification.
@discardableResult
func stop(origin: WebsocketStopOrigin = .swiftTunnelStop) -> Bool {
lifecycleLock.lock()
defer { lifecycleLock.unlock() }
guard lifecycle != .stopped else { return false }
lifecycle = .stopped
let ok = clientBridge.stop(origin.rawValue)
logger.debug("WebSocket stop → \(ok) origin=\(origin)")
return ok
Expand Down
96 changes: 79 additions & 17 deletions FptnVPN/Services/VPNService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ final class VPNService: ObservableObject {
private var conflictDisconnects: Int = 0
private var remainingFallbackBudget: Int = 0
private var isUserInitiatedDisconnect: Bool = true
private var isFallbackReconnecting: Bool = false
// PR2: cancellable fallback reconnect task replaces the boolean guard.
private var fallbackReconnectTask: Task<Void, Never>?

private let tokenService = TokenService.shared
private let serverSelectionService = ServerSelectionService.shared
Expand Down Expand Up @@ -93,7 +94,7 @@ final class VPNService: ObservableObject {
func disconnect() {
let manager = packetTunnelProvider
isUserInitiatedDisconnect = true
isFallbackReconnecting = false
cancelFallbackReconnect()
connection.isConnected = false
connection.isConnecting = false
connection.isReconnecting = false
Expand Down Expand Up @@ -557,6 +558,7 @@ final class VPNService: ObservableObject {

switch status {
case .connected:
cancelFallbackReconnect()
connection.isConnected = true
connection.isConnecting = false
connection.isReconnecting = false
Expand All @@ -570,6 +572,7 @@ final class VPNService: ObservableObject {
let maxAttempts = SettingsService.shared.maxReconnectAttempts
remainingFallbackBudget = maxAttempts == 0 ? Int.max : maxAttempts
case .reasserting:
cancelFallbackReconnect()
connection.isConnected = true
connection.isConnecting = false
connection.isReconnecting = true
Expand All @@ -579,6 +582,7 @@ final class VPNService: ObservableObject {
startTimer()
stopSpeedMonitoring()
case .connecting:
cancelFallbackReconnect()
connection.isConnected = false
connection.isConnecting = true
connection.isReconnecting = false
Expand All @@ -595,7 +599,7 @@ final class VPNService: ObservableObject {
logLastDisconnectError(from: tunnelConnection)
detectConflictAfterDisconnect()

// If it's an unexpected disconnect (connection drop) and fallback reconnect is enabled
// PR2: gated fallback reconnect with heartbeat check.
if !isUserInitiatedDisconnect && SettingsService.shared.reconnectEnabled {
triggerFallbackReconnect()
}
Expand Down Expand Up @@ -705,36 +709,94 @@ final class VPNService: ObservableObject {
}
}

// PR2: fallback reconnect decision.
private enum FallbackReconnectDecision {
case doNotReconnect
case reconnectNow
case recheckAfter(TimeInterval)
}

private func cancelFallbackReconnect() {
fallbackReconnectTask?.cancel()
fallbackReconnectTask = nil
}

// PR2: evaluate whether to reconnect, defer, or suppress.
private func evaluateFallbackDecision() -> FallbackReconnectDecision {
guard !isUserInitiatedDisconnect else { return .doNotReconnect }
guard SettingsService.shared.reconnectEnabled else { return .doNotReconnect }

// Check provider heartbeat staleness.
let heartbeat = TunnelDiagnosticsStore.shared.readHeartbeat()
let heartbeatAge = heartbeat.flatMap {
TunnelDiagnosticsStore.ageSeconds(since: $0.timestamp)
}

// Fresh heartbeat (< 45s): provider is alive and handling its
// own reconnect. Defer recheck until the heartbeat goes stale.
if let age = heartbeatAge, age < 45 {
return .recheckAfter(TimeInterval(45 - age))
}

// PR2: use typed fields, not lastEvent strings.
// If the provider performed a controlled stop, don't restart.
// Raw values match LocalStopInitiator: "app_disconnect", "system_stop".
if let initiator = heartbeat?.localStopInitiator,
initiator == "app_disconnect" || initiator == "system_stop" {
return .doNotReconnect
}

return .reconnectNow
}

private func triggerFallbackReconnect() {
guard !isUserInitiatedDisconnect else { return }
guard !isFallbackReconnecting else { return }

isFallbackReconnecting = true

Task { @MainActor in
defer { isFallbackReconnecting = false }

cancelFallbackReconnect()

fallbackReconnectTask = Task { @MainActor in
// PR2: recheck loop — a fresh heartbeat defers, not
// permanently suppresses. Keep checking until stale.
decisionLoop: while !Task.isCancelled {
switch evaluateFallbackDecision() {
case .doNotReconnect:
return
case .reconnectNow:
break decisionLoop
case .recheckAfter(let seconds):
logger.info("Fallback reconnect deferred \(String(format: "%.0f", seconds))s (provider heartbeat fresh)")
try? await Task.sleep(for: .seconds(max(1, seconds)))
}
}
guard !Task.isCancelled else { return }

let delaySeconds = SettingsService.shared.reconnectDelay
logger.info("Scheduling fallback reconnect after \(delaySeconds)s")
try? await Task.sleep(for: .seconds(delaySeconds))

guard !Task.isCancelled else { return }
guard !isUserInitiatedDisconnect else { return }


// PR2: recheck actual NE status after sleeping.
guard packetTunnelProvider?.connection.status == .disconnected else {
logger.info("Fallback reconnect cancelled: tunnel no longer disconnected")
return
}

// PR2: fix budget off-by-one. Check BEFORE decrementing
// so a budget of N allows exactly N attempts.
let maxAttempts = SettingsService.shared.maxReconnectAttempts
if maxAttempts != 0 {
remainingFallbackBudget -= 1
if remainingFallbackBudget <= 0 {
logger.warning("Reconnection budget exhausted. Stopping reconnection attempts.")
connection.errorMessage = "All servers unreachable (attempts exhausted)"
return
}
remainingFallbackBudget -= 1
}

logger.info("Triggering fallback reconnect attempt. Remaining budget: \(maxAttempts == 0 ? "infinite" : String(remainingFallbackBudget))")

connection.isReconnecting = true
connection.runtimeState = .starting

await performConnect()
}
}
Expand Down
71 changes: 70 additions & 1 deletion FptnVPNTests/FptnVPNTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,10 @@ struct FptnVPNTests {
transportReceivedPackets: 12,
websocketSendFailures: 0,
memoryResidentBytes: 123,
memoryPhysFootprintBytes: 456
memoryPhysFootprintBytes: 456,
localStopInitiator: nil,
nativeDisconnectCode: nil,
nativeStopOrigin: nil
)
)
try #"{"timestamp":"signal-time-unavailable","signal":11,"signalName":"SIGSEGV","process":"FptnVPNTunnel"}"#
Expand Down Expand Up @@ -493,6 +496,72 @@ struct FptnVPNTests {
#expect(invalidPackets == 1)
}

// PR2: disconnect code and stop origin enum mapping.
@Test func disconnectCodeAndStopOriginMapBridgeValues() {
#expect(WebsocketDisconnectCode(bridgeValue: 0) == .none)
#expect(WebsocketDisconnectCode(bridgeValue: 1) == .peerClosed)
#expect(WebsocketDisconnectCode(bridgeValue: 5) == .watchdog)
#expect(WebsocketDisconnectCode(bridgeValue: 6) == .localStop)
#expect(WebsocketDisconnectCode(bridgeValue: 99) == .unknown)

#expect(WebsocketStopOrigin(bridgeValue: 0) == .none)
#expect(WebsocketStopOrigin(bridgeValue: 1) == .swiftTunnelStop)
#expect(WebsocketStopOrigin(bridgeValue: 2) == .swiftReconnect)
#expect(WebsocketStopOrigin(bridgeValue: 3) == .nativeFailure)
#expect(WebsocketStopOrigin(bridgeValue: 4) == .peer)
#expect(WebsocketStopOrigin(bridgeValue: 200) == .unknown)
}

// PR2: heartbeat with typed stop fields decodes correctly.
@Test func heartbeatDecodesTypedStopFields() throws {
let root = try temporaryDiagnosticsDirectory()
let store = TunnelDiagnosticsStore(rootURL: root)
store.writeHeartbeat(
TunnelProviderHeartbeat(
timestamp: TunnelDiagnosticsStore.now(),
runtimeState: "connected",
isReasserting: false,
generation: 1,
reconnectAttempt: 0,
maxReconnectAttempts: 5,
pathSatisfied: true,
websocketStarted: true,
websocketRunning: true,
lastTransportError: nil,
lastStopReason: nil,
lastEvent: "telemetry",
lastInboundActivityAt: nil,
lastOutboundActivityAt: nil,
packetFlowReadPackets: 0,
packetFlowWritePackets: 0,
transportReceivedPackets: 0,
websocketSendFailures: 0,
memoryResidentBytes: nil,
memoryPhysFootprintBytes: nil,
localStopInitiator: "app_disconnect",
nativeDisconnectCode: 6,
nativeStopOrigin: 1
)
)
let heartbeat = store.readHeartbeat()
#expect(heartbeat?.localStopInitiator == "app_disconnect")
#expect(heartbeat?.nativeDisconnectCode == 6)
#expect(heartbeat?.nativeStopOrigin == 1)
}

// PR2: old heartbeat without typed fields still decodes (backward compat).
@Test func heartbeatDecodesWithoutTypedStopFields() throws {
let root = try temporaryDiagnosticsDirectory()
try #"{"timestamp":"2026-01-01T00:00:00Z","runtimeState":"connected","isReasserting":false,"generation":1,"reconnectAttempt":0,"maxReconnectAttempts":5,"pathSatisfied":true,"websocketStarted":true,"websocketRunning":true,"lastTransportError":null,"lastStopReason":null,"lastEvent":"telemetry","lastInboundActivityAt":null,"lastOutboundActivityAt":null,"packetFlowReadPackets":0,"packetFlowWritePackets":0,"transportReceivedPackets":0,"websocketSendFailures":0,"memoryResidentBytes":null,"memoryPhysFootprintBytes":null}"#
.write(to: root.appendingPathComponent("provider-heartbeat.json"), atomically: true, encoding: .utf8)
let store = TunnelDiagnosticsStore(rootURL: root)
let heartbeat = store.readHeartbeat()
#expect(heartbeat != nil)
#expect(heartbeat?.localStopInitiator == nil)
#expect(heartbeat?.nativeDisconnectCode == nil)
#expect(heartbeat?.nativeStopOrigin == nil)
}

private func temporaryDiagnosticsDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("FptnVPNTests-\(UUID().uuidString)", isDirectory: true)
Expand Down
17 changes: 16 additions & 1 deletion FptnVPNTunnel/Cpp/WebScoketClientBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ final class WebsocketClientBridge {
private let ipAssignedCallback: IPAssignedCallback
private let diagnosticsID = UUID().uuidString

// PR2: one-shot lifecycle latch. Once stop() wins, start() is
// permanently rejected. Prevents resurrection after shutdown.
private enum Lifecycle { case created, started, stopped }
private let lifecycleLock = NSLock()
private var lifecycle: Lifecycle = .created

// MARK: - Init / deinit

init(
Expand Down Expand Up @@ -136,17 +142,26 @@ final class WebsocketClientBridge {

// MARK: - Control

// PR2: lifecycle-locked start. Rejected if already started or stopped.
@discardableResult
func start() -> Bool {
lifecycleLock.lock()
defer { lifecycleLock.unlock() }
guard lifecycle == .created else { return false }
let ok = clientBridge.start()
lifecycle = ok ? .started : .stopped
logger.debug("WebSocket start → \(ok)")
TunnelDiagnosticsStore.shared.recordProviderEvent(category: "bridge", message: "start id=\(diagnosticsID) ok=\(ok)")
return ok
}

// PR1C: stop accepts an origin for disconnect classification.
// PR2: lifecycle-locked stop. Once stopped, start() is permanently rejected.
@discardableResult
func stop(origin: WebsocketStopOrigin = .swiftTunnelStop) -> Bool {
lifecycleLock.lock()
defer { lifecycleLock.unlock() }
guard lifecycle != .stopped else { return false }
lifecycle = .stopped
let ok = clientBridge.stop(origin.rawValue)
logger.debug("WebSocket stop → \(ok) origin=\(origin)")
TunnelDiagnosticsStore.shared.recordProviderEvent(category: "bridge", message: "stop id=\(diagnosticsID) ok=\(ok) origin=\(origin)")
Expand Down
Loading
Loading