Skip to content

macOS UI stays disconnected when WireGuard starts later via On Demand #33

Description

@samharshe

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

  1. When any managed VPN protocol transitions to connecting or connected, the app identifies that protocol and updates the UI state.
  2. Disconnect disables On Demand and stops the manager that is actually active, even if the cached UI state says disconnected.
  3. 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:

  1. Initial active-protocol scan finds no active manager and selects IKE.
  2. The saved WireGuard manager later transitions to .connecting and then .connected through On Demand.
  3. The app switches currentVpnProtocol to WireGuard and reports the real connection state.
  4. Disconnect disables WireGuard On Demand and calls stopVPNTunnel() on the WireGuard manager.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions