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
43 changes: 35 additions & 8 deletions CloudTunnelExtension/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CmuxNextCloudTunnel
import Foundation
import Network
import NetworkExtension
import os

Expand All @@ -10,8 +11,9 @@ private let logger = Logger(subsystem: "com.cmuxterm.app.tunnel", category: "pro
/// the config arrives in `providerConfiguration` (see ``CloudTunnelProviderConfiguration``).
final class PacketTunnelProvider: NEPacketTunnelProvider {
private let engine = TunnelEngine()
// The KVO token for `defaultPath` (NEProvider exposes path changes only through KVO).
private var pathObservation: NSKeyValueObservation?
// The network path watch (NWPathMonitor on its own queue). NEProvider's `defaultPath` is not
// available in the macOS 26.5 SDK; Network.framework's monitor is the documented replacement.
private var pathWatch: PathWatch?

override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) {
let completion = ProviderCompletion(completionHandler)
Expand Down Expand Up @@ -47,16 +49,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
}
}
}
pathObservation = observe(\.defaultPath, options: [.old, .new]) { _, change in
// Only a real change (Wi-Fi to Ethernet, wake): the first value is the current path.
guard let old = change.oldValue ?? nil, let new = change.newValue ?? nil, !old.isEqual(to: new) else { return }
Task { await engine.networkChanged() }
}
pathWatch?.cancel()
pathWatch = PathWatch(engine: engine)
}

override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
logger.notice("stopping, reason \(reason.rawValue, privacy: .public)")
pathObservation = nil
pathWatch?.cancel()
pathWatch = nil
let completion = ProviderCompletion { _ in completionHandler() }
let engine = engine
Task {
Expand All @@ -81,6 +81,33 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
}
}

/// The provider's network path watch: an `NWPathMonitor` on its own serial queue (never the main
/// thread), through the path rule ``CloudTunnelPathChangeGate``: the monitor's first path is the
/// current one, so only a real change after it (Wi-Fi to Ethernet, wake) rebinds the engine.
private final class PathWatch: @unchecked Sendable {
// Safe: `gate` is read and written only in the monitor's handler, which runs on `queue`, a
// serial queue; `monitor` and `queue` never change and are thread-safe.
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.cmuxterm.app.tunnel.path", qos: .utility)
private var gate = CloudTunnelPathChangeGate<Network.NWPath>()

init(engine: TunnelEngine) {
monitor.pathUpdateHandler = { [weak self] path in
guard let self, self.admit(path) else { return }
// task-owner: one rebind per real change; the engine actor orders it with start and stop.
Task { await engine.networkChanged() }
}
monitor.start(queue: queue)
}

deinit { monitor.cancel() }

func cancel() { monitor.cancel() }

/// On `queue` only (the monitor's handler).
private func admit(_ path: Network.NWPath) -> Bool { gate.admit(path) }
}

/// Carries an app-message reply handler into a task.
private final class MessageReply: @unchecked Sendable {
// Safe: immutable, called exactly once, from any thread (NetworkExtension allows it).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/// The packet tunnel's path rule: which network path updates mean the engine must rebind.
///
/// `NWPathMonitor` calls its handler once with the current path when it starts, then on every
/// update. The first path is the one the engine started on, so it is not a change; a later path
/// that equals the last one (a repeated update) is not a change either. Only a real change
/// (Wi-Fi to Ethernet, a wake on another network) passes, and then the engine rebinds its UDP
/// socket and handshakes again.
///
/// Generic over the path so the rule is testable without a tunnel; the extension uses `NWPath`.
/// Not thread-safe: the owner calls it from one serial queue (the monitor's).
public struct CloudTunnelPathChangeGate<Path: Equatable> {
private var last: Path?

public init() {}

/// Records `path` and returns whether it is a real change from the path before it.
public mutating func admit(_ path: Path) -> Bool {
defer { last = path }
guard let last else { return false }
return last != path
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import CmuxNextCloudTunnel
import Testing

/// The packet tunnel rebinds only on a real path change, never on the monitor's first path.
@Suite struct CloudTunnelPathChangeGateTests {
@Test func theFirstPathIsNotAChange() {
var gate = CloudTunnelPathChangeGate<String>()
let first = gate.admit("wifi")
#expect(!first)
}

@Test func aRepeatedPathIsNotAChange() {
var gate = CloudTunnelPathChangeGate<String>()
let changes = ["wifi", "wifi", "wifi"].map { gate.admit($0) }
#expect(changes == [false, false, false])
}

@Test func eachRealChangePassesOnce() {
var gate = CloudTunnelPathChangeGate<String>()
let changes = ["wifi", "ethernet", "ethernet", "wifi", "none", "none", "wifi"].map { gate.admit($0) }
#expect(changes == [false, true, false, true, true, false, true])
}
}
Loading