Summary
On macOS, Proton VPN can report disconnected while its persisted WireGuard profile is connected and carrying the default route. This occurs when the app checks the active protocol before macOS starts the WireGuard On Demand profile.
Once the app defaults currentVpnProtocol to IKE, later NEVPNStatusDidChange notifications only cause it to recheck IKE. The WireGuard transition is missed. A subsequent disconnect can therefore operate on the IKE manager and leave the actual WireGuard tunnel and On Demand rule running.
Environment
- Proton VPN 6.5.1 (
3106797.2605011144), matching tag mac/6.5.1
- macOS 26.3 (
25D125)
- Apple silicon (
arm64)
- WireGuard connection profile
- User auto-connect disabled
includeAllNetworks = 0
Observed timeline
The following timestamps came from Proton's application log and the macOS unified NetworkExtension log during one startup:
14:39:40.790 Proton applicationDidFinishLaunching
14:39:41.573 Proton: No active protocols detected. Defaulting to `.ike`
14:40:00.976 nesessionmanager: got On Demand start message
14:40:01.486 nesessionmanager: status changed to connecting
14:40:02.781 nesessionmanager: ProtonVPN became primary for IPv4
14:40:02.862 nesessionmanager: status changed to connected
Afterward:
- Proton's UI remained disconnected.
- Proton logged
Skipping auto-connect: auto-connect disabled for user on wake.
- Proton's maintenance task repeatedly logged
No active connection.
scutil --nc status ProtonVPN reported Connected with On Demand enabled.
- The default route and DNS were assigned to the Proton tunnel.
- Stopping the service with
scutil --nc stop ProtonVPN caused macOS to reconnect it immediately through the saved On Demand Connect rule.
Disabling the ProtonVPN network service stopped the loop.
Relevant code path
The active-protocol scan defaults to IKE when neither manager is active at the instant it runs:
|
@Sendable |
|
func determineActiveVpnProtocolSync(defaultToIke: Bool, completion: @escaping (@MainActor (VpnProtocol?) -> Void)) { |
|
let protocols: [VpnProtocol] = [.ike, .wireGuard(.udp)] |
|
var activeProtocols: [VpnProtocol] = [] |
|
|
|
let dispatchGroup = DispatchGroup() |
|
for vpnProtocol in protocols { |
|
dispatchGroup.enter() |
|
getFactory(for: vpnProtocol).vpnProviderManager(for: .status) { manager, error in |
|
defer { dispatchGroup.leave() } |
|
guard let manager else { |
|
guard let error else { return } |
|
|
|
log.error("Couldn't determine if protocol \"\(vpnProtocol.localizedDescription)\" is active: \"\(String(describing: error))\"", category: .connection) |
|
return |
|
} |
|
|
|
let state = determineNewState(vpnManager: manager) |
|
if state.stableConnection || state.volatileConnection { |
|
activeProtocols.append(vpnProtocol) |
|
} |
|
} |
|
} |
|
|
|
dispatchGroup.notify(queue: .main) { |
|
// WireGuard takes precedence but if neither are active, then it should remain unchanged |
|
if activeProtocols.contains(.wireGuard(.udp)) { |
|
return MainActor.assumeIsolated { |
|
completion(.wireGuard(.udp)) |
|
} |
|
} |
|
if activeProtocols.contains(.ike) { |
|
return MainActor.assumeIsolated { |
|
completion(.ike) |
|
} |
|
} |
|
if defaultToIke { |
|
log.info("No active protocols detected. Defaulting to `.ike`", category: .connection) |
|
return MainActor.assumeIsolated { |
|
completion(.ike) |
|
} |
|
} |
|
return MainActor.assumeIsolated { |
|
completion(nil) |
|
} |
|
} |
prepareManagers() stores that result in currentVpnProtocol. The observer listens for every NEVPNStatusDidChange, but vpnStatusChanged() only calls setState():
|
private func prepareManagers(forSetup _: Bool = false) { |
|
vpnStateConfiguration.determineActiveVpnProtocolSync(defaultToIke: true) { [weak self] vpnProtocol in |
|
guard let self else { |
|
return |
|
} |
|
|
|
currentVpnProtocol = vpnProtocol |
|
setState() |
|
|
|
notificationCenter.removeObserver( |
|
self, |
|
name: NSNotification.Name.NEVPNStatusDidChange, |
|
object: nil |
|
) |
|
notificationCenter.addObserver( |
|
self, |
|
selector: #selector(vpnStatusChanged), |
|
name: NSNotification.Name.NEVPNStatusDidChange, |
|
object: nil |
|
) |
|
} |
|
} |
|
|
|
@MainActor |
|
private func prepareManagers() async { |
|
let vpnProtocol = await vpnStateConfiguration.determineActiveVpnProtocol(defaultToIke: true) |
|
|
|
currentVpnProtocol = vpnProtocol |
|
setState() |
|
|
|
notificationCenter.removeObserver( |
|
self, |
|
name: NSNotification.Name.NEVPNStatusDidChange, |
|
object: nil |
|
) |
|
notificationCenter.addObserver( |
|
self, |
|
selector: #selector(vpnStatusChanged), |
|
name: NSNotification.Name.NEVPNStatusDidChange, |
|
object: nil |
|
) |
|
} |
|
|
|
@objc |
|
private func vpnStatusChanged() { |
|
setState() |
|
} |
setState() queries only the manager selected by the stale currentVpnProtocol value:
|
private func setState(withError error: Error? = nil) { |
|
if let error { |
|
log.error("VPN error: \(error)", category: .connection) |
|
state = .error(error) |
|
disconnectCompletion?() |
|
disconnectCompletion = nil |
|
stateChanged?() |
|
return |
|
} |
|
|
|
guard let vpnProtocol = currentVpnProtocol else { |
|
return |
|
} |
|
|
|
vpnStateConfiguration.determineActiveVpnStateSync(vpnProtocol: vpnProtocol) { [weak self] result in |
|
guard let self, !self.quickReconnection else { |
The disconnect path also uses the selected protocol manager. When the cached state is .disconnected, it runs the completion without calling stopVPNTunnel():
|
private func startDisconnect(completion: @escaping (() -> Void)) { |
|
log.info("Closing VPN tunnel", category: .connectionDisconnect) |
|
|
|
localAgent?.disconnect() |
|
disconnectCompletion = completion |
|
|
|
setOnDemand(false) { vpnManager in |
|
self.stopTunnelOrRunCompletion(vpnManager: vpnManager) |
|
} |
|
} |
|
|
|
private func stopTunnelOrRunCompletion(vpnManager: NEVPNManagerWrapper) { |
|
switch state { |
|
case .disconnected, .error, .invalid: |
|
disconnectCompletion?() // ensures the completion handler is run already disconnected |
|
disconnectCompletion = nil |
|
default: |
|
vpnManager.vpnConnection.stopVPNTunnel() |
|
} |
|
} |
|
|
|
// MARK: - Connect on demand |
|
|
|
private func setOnDemand(_ enabled: Bool, completion: @escaping (NEVPNManagerWrapper) -> Void) { |
|
guard let currentVpnProtocolFactory else { |
|
return |
|
} |
|
|
|
currentVpnProtocolFactory.vpnProviderManager(for: .configuration) { [weak self] vpnManager, error in |
|
guard let self else { |
|
return |
|
} |
|
|
|
if let error { |
|
setState(withError: error) |
|
return |
|
} |
|
|
|
guard let vpnManager else { |
|
setState(withError: CommonVpnError.vpnManagerUnavailable) |
|
return |
|
} |
|
|
|
vpnManager.onDemandRules = [NEOnDemandRuleConnect()] |
|
vpnManager.isOnDemandEnabled = enabled |
|
log.info("On Demand set: \(enabled ? "On" : "Off") for \(currentVpnProtocolFactory.self)", category: .connectionConnect) |
|
|
|
vpnManager.saveToPreferences { [weak self] error in |
|
guard let self else { |
|
return |
|
} |
|
|
|
if let error { |
|
setState(withError: error) |
|
return |
|
} |
|
|
|
completion(vpnManager) |
|
} |
The same state-selection and disconnect logic is present on the current public develop branch.
Expected behavior
- When any managed VPN protocol transitions to connecting or connected, the app identifies that protocol and updates the UI state.
- Disconnect disables On Demand and stops the manager that is actually active, even if the cached UI state says disconnected.
- Auto-connect and On Demand state remain understandable and controllable from the app after restart.
Suggested fix
On NEVPNStatusDidChange, rescan both protocol managers with defaultToIke: false, update currentVpnProtocol when an active protocol is found, and then refresh state. The disconnect path should also reconcile the real active manager before disabling On Demand and stopping the tunnel.
A regression test could model this sequence:
- Initial active-protocol scan finds no active manager and selects IKE.
- The saved WireGuard manager later transitions to
.connecting and then .connected through On Demand.
- The app switches
currentVpnProtocol to WireGuard and reports the real connection state.
- Disconnect disables WireGuard On Demand and calls
stopVPNTunnel() on the WireGuard manager.
Summary
On macOS, Proton VPN can report
disconnectedwhile its persisted WireGuard profile is connected and carrying the default route. This occurs when the app checks the active protocol before macOS starts the WireGuard On Demand profile.Once the app defaults
currentVpnProtocolto IKE, laterNEVPNStatusDidChangenotifications only cause it to recheck IKE. The WireGuard transition is missed. A subsequent disconnect can therefore operate on the IKE manager and leave the actual WireGuard tunnel and On Demand rule running.Environment
3106797.2605011144), matching tagmac/6.5.125D125)arm64)includeAllNetworks = 0Observed timeline
The following timestamps came from Proton's application log and the macOS unified NetworkExtension log during one startup:
Afterward:
Skipping auto-connect: auto-connect disabled for useron wake.No active connection.scutil --nc status ProtonVPNreportedConnectedwith On Demand enabled.scutil --nc stop ProtonVPNcaused macOS to reconnect it immediately through the saved On Demand Connect rule.Disabling the ProtonVPN network service stopped the loop.
Relevant code path
The active-protocol scan defaults to IKE when neither manager is active at the instant it runs:
ios-mac-app/libraries/Core/LegacyCommon/Sources/LegacyCommon/Core/VpnStateConfiguration.swift
Lines 131 to 176 in 2704c51
prepareManagers()stores that result incurrentVpnProtocol. The observer listens for everyNEVPNStatusDidChange, butvpnStatusChanged()only callssetState():ios-mac-app/libraries/Core/LegacyCommon/Sources/LegacyCommon/Core/VpnManager.swift
Lines 786 to 832 in 2704c51
setState()queries only the manager selected by the stalecurrentVpnProtocolvalue:ios-mac-app/libraries/Core/LegacyCommon/Sources/LegacyCommon/Core/VpnManager.swift
Lines 653 to 668 in 2704c51
The disconnect path also uses the selected protocol manager. When the cached state is
.disconnected, it runs the completion without callingstopVPNTunnel():ios-mac-app/libraries/Core/LegacyCommon/Sources/LegacyCommon/Core/VpnManager.swift
Lines 591 to 649 in 2704c51
The same state-selection and disconnect logic is present on the current public
developbranch.Expected behavior
Suggested fix
On
NEVPNStatusDidChange, rescan both protocol managers withdefaultToIke: false, updatecurrentVpnProtocolwhen an active protocol is found, and then refresh state. The disconnect path should also reconcile the real active manager before disabling On Demand and stopping the tunnel.A regression test could model this sequence:
.connectingand then.connectedthrough On Demand.currentVpnProtocolto WireGuard and reports the real connection state.stopVPNTunnel()on the WireGuard manager.