From 7637a2740cf7da1046c48a66419853918ea8c2cd Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:54:31 +0000 Subject: [PATCH 1/3] CmuxNextCloudTunnel: the packet tunnel's path rule as a testable gate CloudTunnelPathChangeGate holds the rule the extension applied inline: the first path is the current one, so only a later path that differs from the one before it is a real change. Generic over the path, so the tests need no tunnel. --- .../CloudTunnelPathChangeGate.swift | 22 ++++++++++++++++++ .../CloudTunnelPathChangeGateTests.swift | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextCloudTunnel/CloudTunnelPathChangeGate.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextCloudTunnelTests/CloudTunnelPathChangeGateTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextCloudTunnel/CloudTunnelPathChangeGate.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudTunnel/CloudTunnelPathChangeGate.swift new file mode 100644 index 000000000000..ae2efe097695 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextCloudTunnel/CloudTunnelPathChangeGate.swift @@ -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 { + 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 + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTunnelTests/CloudTunnelPathChangeGateTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTunnelTests/CloudTunnelPathChangeGateTests.swift new file mode 100644 index 000000000000..2f93d87e2963 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTunnelTests/CloudTunnelPathChangeGateTests.swift @@ -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() + #expect(!gate.admit("wifi")) + } + + @Test func aRepeatedPathIsNotAChange() { + var gate = CloudTunnelPathChangeGate() + _ = gate.admit("wifi") + #expect(!gate.admit("wifi")) + #expect(!gate.admit("wifi")) + } + + @Test func eachRealChangePassesOnce() { + var gate = CloudTunnelPathChangeGate() + let changes = ["wifi", "ethernet", "ethernet", "wifi", "none", "none", "wifi"].map { gate.admit($0) } + #expect(changes == [false, true, false, true, true, false, true]) + } +} From 36700430d11e23713e4d5bb19ead38a5cc89b879 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:54:32 +0000 Subject: [PATCH 2/3] base red: Cloud Tunnel extension observes the path with NWPathMonitor (defaultPath is not in the macOS 26.5 SDK) The full Release app build (fleet job a565ad1d55c788476e604c39, feat 7fd393100087, Xcode 26.6, MacOSX26.5 SDK) fails: CloudTunnelExtension/PacketTunnelProvider.swift:50:35: error: value of type 'PacketTunnelProvider' has no member 'defaultPath' 010ffb95c155 observed NEProvider's defaultPath through KVO. This SDK does not offer it to Swift; Network.framework's NWPathMonitor is the documented replacement. The provider now runs one NWPathMonitor on its own serial utility queue (not the main thread), started where the KVO observation was and cancelled on stop. The same rule holds through CloudTunnelPathChangeGate: the monitor's first path is the current one, so only a real change after it calls engine.networkChanged(). Fixes the build break from 010ffb95c155 (lawrencecchen). --- .../PacketTunnelProvider.swift | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/CloudTunnelExtension/PacketTunnelProvider.swift b/CloudTunnelExtension/PacketTunnelProvider.swift index c2823fb468bd..fde2db77d0a2 100644 --- a/CloudTunnelExtension/PacketTunnelProvider.swift +++ b/CloudTunnelExtension/PacketTunnelProvider.swift @@ -1,5 +1,6 @@ import CmuxNextCloudTunnel import Foundation +import Network import NetworkExtension import os @@ -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) @@ -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 { @@ -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() + + 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: 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). From b41c5e80522b5c1afe771e2f26c0b611608d4971 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:09:11 +0000 Subject: [PATCH 3/3] Cloud Tunnel path gate: tests read admit() outside #expect (a mutating call in its closure does not compile); the extension names Network.NWPath --- CloudTunnelExtension/PacketTunnelProvider.swift | 4 ++-- .../CloudTunnelPathChangeGateTests.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CloudTunnelExtension/PacketTunnelProvider.swift b/CloudTunnelExtension/PacketTunnelProvider.swift index fde2db77d0a2..dbaba775b645 100644 --- a/CloudTunnelExtension/PacketTunnelProvider.swift +++ b/CloudTunnelExtension/PacketTunnelProvider.swift @@ -89,7 +89,7 @@ private final class PathWatch: @unchecked Sendable { // 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() + private var gate = CloudTunnelPathChangeGate() init(engine: TunnelEngine) { monitor.pathUpdateHandler = { [weak self] path in @@ -105,7 +105,7 @@ private final class PathWatch: @unchecked Sendable { func cancel() { monitor.cancel() } /// On `queue` only (the monitor's handler). - private func admit(_ path: NWPath) -> Bool { gate.admit(path) } + private func admit(_ path: Network.NWPath) -> Bool { gate.admit(path) } } /// Carries an app-message reply handler into a task. diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTunnelTests/CloudTunnelPathChangeGateTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTunnelTests/CloudTunnelPathChangeGateTests.swift index 2f93d87e2963..d121c0604e35 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTunnelTests/CloudTunnelPathChangeGateTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextCloudTunnelTests/CloudTunnelPathChangeGateTests.swift @@ -5,14 +5,14 @@ import Testing @Suite struct CloudTunnelPathChangeGateTests { @Test func theFirstPathIsNotAChange() { var gate = CloudTunnelPathChangeGate() - #expect(!gate.admit("wifi")) + let first = gate.admit("wifi") + #expect(!first) } @Test func aRepeatedPathIsNotAChange() { var gate = CloudTunnelPathChangeGate() - _ = gate.admit("wifi") - #expect(!gate.admit("wifi")) - #expect(!gate.admit("wifi")) + let changes = ["wifi", "wifi", "wifi"].map { gate.admit($0) } + #expect(changes == [false, false, false]) } @Test func eachRealChangePassesOnce() {