From 12493bc0d4584242ea11856efed2ef984a06218f Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:20:33 -0700 Subject: [PATCH 1/8] fix(ios): retry transient Tailscale readiness --- .../CmxPreparingTailscaleByteTransport.swift | 53 ++++++++++++++----- .../CmxTailscaleRouteProof.swift | 18 +++++++ .../CmxTailscaleRouteProofTests.swift | 9 ++++ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift index 721f3b20d7fa..a59a3e73868c 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift @@ -1,10 +1,17 @@ internal import CMUXMobileCore import Foundation +import os + +nonisolated private let tailscalePreparationLog = Logger( + subsystem: "com.manaflow.cmux", + category: "TailscalePreparation" +) /// Defers the actor-isolated route proof until `connect()` while preserving the /// synchronous transport-factory contract. The proven interface is set on /// `NWParameters` before Network.framework starts the connection. actor CmxPreparingTailscaleByteTransport: CmxByteTransport { + private static let maximumPreparationAttempts = 3 private let request: CmxByteTransportRequest private let tailscaleRouteAuthority: any CmxTailscaleRouteAuthorizing private let maximumReceiveLength: Int @@ -74,21 +81,39 @@ actor CmxPreparingTailscaleByteTransport: CmxByteTransport { let maximumReceiveLength = maximumReceiveLength let connectTimeoutNanoseconds = connectTimeoutNanoseconds task = Task { - do { - let prepared = try await authority.prepare(request: request) - try Task.checkCancellation() - return try CmxNetworkByteTransport( - request: request, - preparedTailscaleRoute: prepared, - tailscaleRouteAuthority: authority, - maximumReceiveLength: maximumReceiveLength, - connectTimeoutNanoseconds: connectTimeoutNanoseconds - ) - } catch is CancellationError { - throw CancellationError() - } catch { - throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable + for attempt in 1...Self.maximumPreparationAttempts { + do { + let prepared = try await authority.prepare(request: request) + try Task.checkCancellation() + return try CmxNetworkByteTransport( + request: request, + preparedTailscaleRoute: prepared, + tailscaleRouteAuthority: authority, + maximumReceiveLength: maximumReceiveLength, + connectTimeoutNanoseconds: connectTimeoutNanoseconds + ) + } catch is CancellationError { + throw CancellationError() + } catch let error as CmxTailscaleRouteProofError + where error.isTransientReadinessFailure + { + tailscalePreparationLog.debug( + "Tailscale preparation attempt \(attempt, privacy: .public)/\(Self.maximumPreparationAttempts, privacy: .public) deferred: \(String(describing: error), privacy: .public)" + ) + guard attempt < Self.maximumPreparationAttempts else { + throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable + } + // Yield so NWPathMonitor's callback can update the + // authority actor before the next proof attempt. + await Task.yield() + } catch { + tailscalePreparationLog.error( + "Tailscale preparation failed: \(String(describing: error), privacy: .public)" + ) + throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable + } } + throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable } preparationTask = task } diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift index 4f92d71c744f..b6c6254ee489 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift @@ -22,6 +22,24 @@ enum CmxTailscaleRouteProofError: Error, Equatable, Sendable { case remotePortMismatch } +extension CmxTailscaleRouteProofError { + /// Failures caused by the tunnel becoming visible while a pairing attempt + /// is already in flight. These are safe to retry with the same QR grant. + var isTransientReadinessFailure: Bool { + switch self { + case .pathUnavailable, .tailscaleInterfaceUnavailable, + .ambiguousTailscaleInterfaces, .routeGenerationChanged, + .interfaceChanged, .connectionPathUnavailable: + return true + case .unsupportedRouteKind, .unsupportedAuthorizationMode, + .authorizationEvidenceMismatch, .unsupportedEndpoint, + .nonNumericPeer, .peerOutsideTailscaleRange, .peerIsLocalDevice, + .localEndpointMismatch, .remoteEndpointMismatch, .remotePortMismatch: + return false + } + } +} + struct CmxTailscaleIPAddress: Hashable, Sendable { enum Family: Sendable { case ipv4 diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift index dfbbd9a5fe08..b092f6b31a3f 100644 --- a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift +++ b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift @@ -5,6 +5,15 @@ import Testing private let tailscaleInterface = CmxNetworkInterfaceIdentity(name: "utun4", index: 22) @Suite struct CmxTailscaleRouteProofTests { + @Test func classifiesOnlyLiveTunnelStateChangesAsTransientReadinessFailures() { + #expect(CmxTailscaleRouteProofError.pathUnavailable.isTransientReadinessFailure) + #expect(CmxTailscaleRouteProofError.tailscaleInterfaceUnavailable.isTransientReadinessFailure) + #expect(CmxTailscaleRouteProofError.ambiguousTailscaleInterfaces.isTransientReadinessFailure) + #expect(CmxTailscaleRouteProofError.routeGenerationChanged.isTransientReadinessFailure) + #expect(!CmxTailscaleRouteProofError.authorizationEvidenceMismatch.isTransientReadinessFailure) + #expect(!CmxTailscaleRouteProofError.peerOutsideTailscaleRange.isTransientReadinessFailure) + } + @Test func rejectsGenericBearerAndMismatchedLegacyEvidence() throws { let genericBearer = try tailscaleRequest( host: "100.71.210.41", From 086133c5cf87d3a84b5c103f2670365f0961e5e0 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:30:59 -0700 Subject: [PATCH 2/8] fix(ios): wait for initial Tailscale path update --- .../CmxTailscaleRouteAuthority.swift | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift index 03e1ea28a1bd..f5d544d57b1b 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift @@ -25,6 +25,8 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { } private var pathState = PathState() + private var hasReceivedInitialPathUpdate = false + private var initialPathWaiters: [CheckedContinuation] = [] private let monitor: NWPathMonitor init() { @@ -47,7 +49,12 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { monitor.cancel() } - func prepare(request: CmxByteTransportRequest) throws -> CmxPreparedTailscaleRoute { + func prepare(request: CmxByteTransportRequest) async throws -> CmxPreparedTailscaleRoute { + // `currentPath` is not authoritative until NWPathMonitor has delivered + // its first callback. Pairing can begin immediately after the scanner + // returns, so gate the first proof on that callback instead of treating + // the monitor's startup snapshot as a real Tailscale outage. + await waitForInitialPathUpdate() let observed = observedPath() let snapshot = Self.authoritySnapshot( generation: observed.generation, @@ -96,6 +103,22 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { pathState.generation = Self.nextGeneration(after: pathState.generation) pathState.path = path } + guard !hasReceivedInitialPathUpdate else { return } + hasReceivedInitialPathUpdate = true + let waiters = initialPathWaiters + initialPathWaiters.removeAll(keepingCapacity: false) + waiters.forEach { $0.resume() } + } + + private func waitForInitialPathUpdate() async { + guard !hasReceivedInitialPathUpdate else { return } + await withCheckedContinuation { continuation in + if hasReceivedInitialPathUpdate { + continuation.resume() + } else { + initialPathWaiters.append(continuation) + } + } } private static func nextGeneration(after generation: UInt64) -> UInt64 { From 9c2a255d0cc28b76ec96099552a25becf07dce40 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:38:47 -0700 Subject: [PATCH 3/8] debug(ios): instrument Tailscale pairing readiness --- .../MobileShellComposite.swift | 22 ++++++++ .../iOS/CmuxMobileTransport/Package.swift | 3 +- .../CmxPreparingTailscaleByteTransport.swift | 21 +++++++- .../CmxTailscaleRouteAuthority.swift | 52 +++++++++++++++++-- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index e84eb3a47580..5348c77d6f68 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -4394,6 +4394,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { acceptedVersionWarning: Bool, userEnteredPairingCode: Bool = false ) async -> MobilePairingURLConnectionResult { + MobileDebugLog.shared.append( + "pairing.qr_connect.begin user_entered=\(userEnteredPairingCode) accepted_version_warning=\(acceptedVersionWarning)" + ) let rawURL = Self.normalizedPairingURL(rawValue ?? pairingCode) _ = beginPairingValidationAttempt() connectionAttemptGeneration = UUID() @@ -4407,6 +4410,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let ticket: CmxAttachTicket do { ticket = try CmxAttachTicketInput.decode(rawURL) + MobileDebugLog.shared.append( + "pairing.qr_decode.success route_count=\(ticket.routes.count) route_kinds=\(ticket.routes.map(\.kind.rawValue).sorted().joined(separator: ","))" + ) // The v2 grammar rejects loopback inside the decoder; the legacy // grammars must keep decoding loopback for the simulator dev flow // (where 127.0.0.1 IS the host Mac). On a physical phone no @@ -4422,6 +4428,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { throw MobileSyncPairingPayloadError.loopbackRouteRejected } } catch { + MobileDebugLog.shared.append( + "pairing.qr_decode.failed error=\(String(describing: error))" + ) if case MobileSyncPairingPayloadError.loopbackRouteRejected = error { // A scanned/pasted code that only points back at the Mac // itself (127.0.0.1) would make the phone dial itself. Name @@ -4451,6 +4460,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { isDevelopmentAuthEnvironment: identityProvider?.isDevelopmentAuthEnvironment ?? false ) if let emailFailure = accountPreflight.failure(for: ticket) { + MobileDebugLog.shared.append( + "pairing.account_preflight.failed category=\(emailFailure)" + ) applyPairingValidationFailure(emailFailure) if connectionState != .connected { connectionState = .disconnected @@ -4499,6 +4511,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { supportedKinds: runtime?.supportedRouteKinds ?? [], userTailscalePairingAuthorizations: userTailscalePairingAuthorizations ) + MobileDebugLog.shared.append( + "pairing.attempt.started route_count=\(candidateRoutes.count) supported_route_kinds=\(candidateRoutes.map(\.kind.rawValue).sorted().joined(separator: ","))" + ) if !candidateRoutes.isEmpty { switch await failPairingIfOffline(attemptID: attemptID, phase: "preflight", routes: candidateRoutes) { case .failedOffline: return .failed @@ -4522,11 +4537,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { await loadPairedMacs() guard isCurrentPairingAttempt(attemptID) else { return .superseded } recordPairingSucceeded() + MobileDebugLog.shared.append("pairing.attempt.succeeded") return .connected } // `connect()` returned without connecting and already set a // specific error; record without overwriting that message. recordFailureForCurrentConnectionError(phase: "connect", category: noThrowFailure) + MobileDebugLog.shared.append( + "pairing.attempt.failed_without_throw category=\(noThrowFailure)" + ) return .failed } catch is CancellationError { guard isCurrentPairingAttempt(attemptID) else { return .superseded } @@ -4537,6 +4556,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } catch { guard isCurrentPairingAttempt(attemptID) else { return .superseded } mobileShellLog.error("pairing failed: \(String(describing: error), privacy: .private)") + MobileDebugLog.shared.append( + "pairing.attempt.failed error=\(String(describing: error))" + ) // Definitive auth failures drive the re-auth prompt rather than a // generic connection error (matches the manual-host path); the // helper records the analytics failure + guidance. diff --git a/Packages/iOS/CmuxMobileTransport/Package.swift b/Packages/iOS/CmuxMobileTransport/Package.swift index f9ab601955e7..e85173059a81 100644 --- a/Packages/iOS/CmuxMobileTransport/Package.swift +++ b/Packages/iOS/CmuxMobileTransport/Package.swift @@ -16,11 +16,12 @@ let package = Package( ], dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../CmuxMobileDiagnostics"), ], targets: [ .target( name: "CmuxMobileTransport", - dependencies: ["CMUXMobileCore"], + dependencies: ["CMUXMobileCore", "CmuxMobileDiagnostics"], swiftSettings: [ .swiftLanguageMode(.v6), .enableUpcomingFeature("ExistentialAny"), diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift index a59a3e73868c..55c862ba807d 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift @@ -1,6 +1,7 @@ internal import CMUXMobileCore import Foundation import os +import CmuxMobileDiagnostics nonisolated private let tailscalePreparationLog = Logger( subsystem: "com.manaflow.cmux", @@ -33,8 +34,17 @@ actor CmxPreparingTailscaleByteTransport: CmxByteTransport { } func connect() async throws { + MobileDebugLog.shared.append("tailscale.transport.connect.begin") let transport = try await preparedTransport() - try await transport.connect() + do { + try await transport.connect() + MobileDebugLog.shared.append("tailscale.transport.connect.success") + } catch { + MobileDebugLog.shared.append( + "tailscale.transport.connect.failed error=\(String(describing: error))" + ) + throw error + } } func receive() async throws -> Data? { @@ -82,6 +92,9 @@ actor CmxPreparingTailscaleByteTransport: CmxByteTransport { let connectTimeoutNanoseconds = connectTimeoutNanoseconds task = Task { for attempt in 1...Self.maximumPreparationAttempts { + MobileDebugLog.shared.append( + "tailscale.prepare.attempt number=\(attempt)/\(Self.maximumPreparationAttempts)" + ) do { let prepared = try await authority.prepare(request: request) try Task.checkCancellation() @@ -97,6 +110,9 @@ actor CmxPreparingTailscaleByteTransport: CmxByteTransport { } catch let error as CmxTailscaleRouteProofError where error.isTransientReadinessFailure { + MobileDebugLog.shared.append( + "tailscale.prepare.transient_failure attempt=\(attempt) error=\(String(describing: error))" + ) tailscalePreparationLog.debug( "Tailscale preparation attempt \(attempt, privacy: .public)/\(Self.maximumPreparationAttempts, privacy: .public) deferred: \(String(describing: error), privacy: .public)" ) @@ -107,6 +123,9 @@ actor CmxPreparingTailscaleByteTransport: CmxByteTransport { // authority actor before the next proof attempt. await Task.yield() } catch { + MobileDebugLog.shared.append( + "tailscale.prepare.permanent_failure error=\(String(describing: error))" + ) tailscalePreparationLog.error( "Tailscale preparation failed: \(String(describing: error), privacy: .public)" ) diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift index f5d544d57b1b..d0353f2c82a4 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift @@ -1,4 +1,5 @@ internal import CMUXMobileCore +import CmuxMobileDiagnostics import Darwin import Foundation @preconcurrency import Network @@ -50,6 +51,9 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { } func prepare(request: CmxByteTransportRequest) async throws -> CmxPreparedTailscaleRoute { + MobileDebugLog.shared.append( + "tailscale.prepare.begin route=\(request.route.kind.rawValue)" + ) // `currentPath` is not authoritative until NWPathMonitor has delivered // its first callback. Pairing can begin immediately after the scanner // returns, so gate the first proof on that callback instead of treating @@ -60,15 +64,30 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { generation: observed.generation, path: observed.path ) - let proof = try CmxTailscaleRouteProofValidator().prepare( - request: request, - snapshot: snapshot - ) + MobileDebugLog.shared.append(Self.logLine("tailscale.prepare.snapshot", snapshot: snapshot)) + let proof: CmxTailscaleRouteProof + do { + proof = try CmxTailscaleRouteProofValidator().prepare( + request: request, + snapshot: snapshot + ) + } catch { + MobileDebugLog.shared.append( + "tailscale.prepare.proof_failed error=\(String(describing: error))" + ) + throw error + } guard let interface = observed.path.availableInterfaces.first(where: { $0.name == proof.interface.name && $0.index == proof.interface.index }) else { + MobileDebugLog.shared.append( + "tailscale.prepare.interface_failed proof_interface=\(proof.interface.name):\(proof.interface.index) snapshot_interfaces=\(Self.interfaceNames(observed.path))" + ) throw CmxTailscaleRouteProofError.tailscaleInterfaceUnavailable } + MobileDebugLog.shared.append( + "tailscale.prepare.success interface=\(proof.interface.name):\(proof.interface.index) generation=\(proof.generation)" + ) return CmxPreparedTailscaleRoute(proof: proof, requiredInterface: interface) } @@ -102,6 +121,11 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { if pathState.path != path { pathState.generation = Self.nextGeneration(after: pathState.generation) pathState.path = path + let snapshot = Self.authoritySnapshot( + generation: pathState.generation, + path: path + ) + MobileDebugLog.shared.append(Self.logLine("tailscale.path_update", snapshot: snapshot)) } guard !hasReceivedInitialPathUpdate else { return } hasReceivedInitialPathUpdate = true @@ -121,6 +145,26 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { } } + private static func logLine( + _ prefix: String, + snapshot: CmxTailscaleAuthoritySnapshot + ) -> String { + let interfaces = snapshot.systemInterfaces.map { interface in + "\(interface.identity.name):\(interface.identity.index),up=\(interface.isUp),running=\(interface.isRunning),tailnet_addrs=\(interface.tailnetAddresses.count)" + }.sorted().joined(separator: ";") + return "\(prefix) generation=\(snapshot.generation) path_satisfied=\(snapshot.pathSatisfied) available_interfaces=\(interfaceNames(snapshot.availableInterfaces)) system_interfaces=\(interfaces)" + } + + private static func interfaceNames(_ interfaces: Set) -> String { + interfaces.map { "\($0.name):\($0.index)" }.sorted().joined(separator: ",") + } + + private static func interfaceNames(_ path: NWPath) -> String { + interfaceNames(Set(path.availableInterfaces.map { + CmxNetworkInterfaceIdentity(name: $0.name, index: $0.index) + })) + } + private static func nextGeneration(after generation: UInt64) -> UInt64 { generation == .max ? 1 : generation + 1 } From edda52b4aa65aa2262fb4fc779f76d07bd7a93c8 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:09:59 -0700 Subject: [PATCH 4/8] debug(ios): trace Tailscale connection validation --- .../CmxNetworkByteTransport.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift index 09345f9e4b37..a2a635d9f000 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift @@ -1,4 +1,5 @@ public import CMUXMobileCore +import CmuxMobileDiagnostics public import Foundation import Dispatch @preconcurrency public import Network @@ -330,9 +331,15 @@ public actor CmxNetworkByteTransport: CmxByteTransport { guard !isTerminal else { return } + MobileDebugLog.shared.append( + "tailscale.connection.ready path=\(Self.pathSummary(connection.currentPath)) revision=\(tailscalePathRevision)" + ) do { try await validateTailscaleAuthorizationForCurrentPath() } catch { + MobileDebugLog.shared.append( + "tailscale.connection.ready_validation_failed error=\(String(describing: error)) path=\(Self.pathSummary(connection.currentPath)) revision=\(tailscalePathRevision)" + ) failTransport(.tailscaleAuthorizationUnavailable) return } @@ -679,9 +686,15 @@ public actor CmxNetworkByteTransport: CmxByteTransport { private func handleTailscalePathUpdate(_ path: NWPath) async { guard tailscaleBinding != nil, !isTerminal else { return } tailscalePathRevision = tailscalePathRevision == .max ? 1 : tailscalePathRevision + 1 + MobileDebugLog.shared.append( + "tailscale.connection.path_update revision=\(tailscalePathRevision) path=\(Self.pathSummary(path))" + ) do { try await validateTailscaleAuthorization(path: path) } catch { + MobileDebugLog.shared.append( + "tailscale.connection.path_validation_failed error=\(String(describing: error)) revision=\(tailscalePathRevision) path=\(Self.pathSummary(path))" + ) tailscaleAuthorizationInvalidated = true failTransport(.tailscaleAuthorizationUnavailable) } @@ -703,9 +716,15 @@ public actor CmxNetworkByteTransport: CmxByteTransport { private func validateTailscaleAuthorization(path: NWPath) async throws { guard let binding = tailscaleBinding else { return } + MobileDebugLog.shared.append( + "tailscale.authorization.validate_begin path=\(Self.pathSummary(path)) revision=\(tailscalePathRevision)" + ) guard !tailscaleAuthorizationInvalidated, binding.request == binding.preparedRoute.proof.request, connection.parameters.requiredInterface == binding.preparedRoute.requiredInterface else { + MobileDebugLog.shared.append( + "tailscale.authorization.validate_precondition_failed invalidated=\(tailscaleAuthorizationInvalidated) request_matches=\(binding.request == binding.preparedRoute.proof.request) interface_matches=\(connection.parameters.requiredInterface == binding.preparedRoute.requiredInterface)" + ) throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable } do { @@ -713,11 +732,24 @@ public actor CmxNetworkByteTransport: CmxByteTransport { proof: binding.preparedRoute.proof, connectionPath: path ) + MobileDebugLog.shared.append("tailscale.authorization.validate_success") } catch { + MobileDebugLog.shared.append( + "tailscale.authorization.validate_failed underlying=\(String(describing: error)) path=\(Self.pathSummary(path))" + ) throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable } } + private static func pathSummary(_ path: NWPath?) -> String { + guard let path else { return "nil" } + let interfaces = path.availableInterfaces + .map { "\($0.name):\($0.index)" } + .sorted() + .joined(separator: ",") + return "status=\(path.status) interfaces=\(interfaces) local=\(path.localEndpoint != nil) remote=\(path.remoteEndpoint != nil)" + } + /// The single legacy bearer-write boundary. Authorization completes before /// Network.framework receives the send request. func performAuthorizedWrite( From 961e3ec2e953a10a654e382ea688f0a9b29e0236 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:39:31 -0700 Subject: [PATCH 5/8] fix(ios): wait for Tailscale path updates --- .../CmxPreparingTailscaleByteTransport.swift | 6 +- .../CmxTailscaleRouteAuthority.swift | 91 +++++++++++++------ 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift index 55c862ba807d..c8980e352345 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift @@ -119,9 +119,9 @@ actor CmxPreparingTailscaleByteTransport: CmxByteTransport { guard attempt < Self.maximumPreparationAttempts else { throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable } - // Yield so NWPathMonitor's callback can update the - // authority actor before the next proof attempt. - await Task.yield() + // Wait for a real NWPathMonitor transition. Repeating + // immediately only re-reads the same pre-Tailscale path. + await authority.waitForNextPathUpdate() } catch { MobileDebugLog.shared.append( "tailscale.prepare.permanent_failure error=\(String(describing: error))" diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift index d0353f2c82a4..6b1edeeb5449 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift @@ -11,9 +11,14 @@ struct CmxPreparedTailscaleRoute: Sendable { protocol CmxTailscaleRouteAuthorizing: Sendable { func prepare(request: CmxByteTransportRequest) async throws -> CmxPreparedTailscaleRoute + func waitForNextPathUpdate() async func validate(proof: CmxTailscaleRouteProof, connectionPath: NWPath) async throws } +extension CmxTailscaleRouteAuthorizing { + func waitForNextPathUpdate() async {} +} + actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { private struct PathState: Sendable { var generation: UInt64 = 0 @@ -28,11 +33,18 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { private var pathState = PathState() private var hasReceivedInitialPathUpdate = false private var initialPathWaiters: [CheckedContinuation] = [] + private let pathUpdateContinuation: AsyncStream.Continuation + private let pathUpdateStream: AsyncStream private let monitor: NWPathMonitor init() { let monitor = NWPathMonitor() self.monitor = monitor + let pathUpdates = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + self.pathUpdateContinuation = pathUpdates.continuation + self.pathUpdateStream = pathUpdates.stream monitor.pathUpdateHandler = { [weak self] path in guard let self else { return } Task { await self.observe(path) } @@ -59,36 +71,52 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { // returns, so gate the first proof on that callback instead of treating // the monitor's startup snapshot as a real Tailscale outage. await waitForInitialPathUpdate() - let observed = observedPath() - let snapshot = Self.authoritySnapshot( - generation: observed.generation, - path: observed.path - ) - MobileDebugLog.shared.append(Self.logLine("tailscale.prepare.snapshot", snapshot: snapshot)) - let proof: CmxTailscaleRouteProof - do { - proof = try CmxTailscaleRouteProofValidator().prepare( - request: request, - snapshot: snapshot - ) - } catch { - MobileDebugLog.shared.append( - "tailscale.prepare.proof_failed error=\(String(describing: error))" + while true { + let observed = observedPath() + let snapshot = Self.authoritySnapshot( + generation: observed.generation, + path: observed.path ) - throw error - } - guard let interface = observed.path.availableInterfaces.first(where: { - $0.name == proof.interface.name && $0.index == proof.interface.index - }) else { + MobileDebugLog.shared.append(Self.logLine("tailscale.prepare.snapshot", snapshot: snapshot)) + let proof: CmxTailscaleRouteProof + do { + proof = try CmxTailscaleRouteProofValidator().prepare( + request: request, + snapshot: snapshot + ) + } catch let error as CmxTailscaleRouteProofError + where error.isTransientReadinessFailure + { + MobileDebugLog.shared.append( + "tailscale.prepare.proof_failed error=\(String(describing: error)); waiting_for_path_update=true" + ) + await waitForNextPathUpdate() + continue + } catch { + MobileDebugLog.shared.append( + "tailscale.prepare.proof_failed error=\(String(describing: error))" + ) + throw error + } + guard let interface = observed.path.availableInterfaces.first(where: { + $0.name == proof.interface.name && $0.index == proof.interface.index + }) else { + MobileDebugLog.shared.append( + "tailscale.prepare.interface_failed proof_interface=\(proof.interface.name):\(proof.interface.index) snapshot_interfaces=\(Self.interfaceNames(observed.path)); waiting_for_path_update=true" + ) + await waitForNextPathUpdate() + continue + } MobileDebugLog.shared.append( - "tailscale.prepare.interface_failed proof_interface=\(proof.interface.name):\(proof.interface.index) snapshot_interfaces=\(Self.interfaceNames(observed.path))" + "tailscale.prepare.success interface=\(proof.interface.name):\(proof.interface.index) generation=\(proof.generation)" ) - throw CmxTailscaleRouteProofError.tailscaleInterfaceUnavailable + return CmxPreparedTailscaleRoute(proof: proof, requiredInterface: interface) } - MobileDebugLog.shared.append( - "tailscale.prepare.success interface=\(proof.interface.name):\(proof.interface.index) generation=\(proof.generation)" - ) - return CmxPreparedTailscaleRoute(proof: proof, requiredInterface: interface) + } + + func waitForNextPathUpdate() async { + var iterator = pathUpdateStream.makeAsyncIterator() + _ = await iterator.next() } func validate(proof: CmxTailscaleRouteProof, connectionPath: NWPath) throws { @@ -118,7 +146,8 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { } private func observe(_ path: NWPath) { - if pathState.path != path { + let pathChanged = pathState.path != path + if pathChanged { pathState.generation = Self.nextGeneration(after: pathState.generation) pathState.path = path let snapshot = Self.authoritySnapshot( @@ -127,7 +156,13 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { ) MobileDebugLog.shared.append(Self.logLine("tailscale.path_update", snapshot: snapshot)) } - guard !hasReceivedInitialPathUpdate else { return } + let wasInitialPathUpdate = !hasReceivedInitialPathUpdate + guard wasInitialPathUpdate else { + if pathChanged { + pathUpdateContinuation.yield(()) + } + return + } hasReceivedInitialPathUpdate = true let waiters = initialPathWaiters initialPathWaiters.removeAll(keepingCapacity: false) From f5bd695f6508f3c0ea761789327c147865cc1492 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:48:20 -0700 Subject: [PATCH 6/8] fix(ios): preserve Tailscale path wakeups --- .../CmuxMobileTransport/CmxTailscaleRouteAuthority.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift index 6b1edeeb5449..be72dc10ff1c 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift @@ -158,9 +158,10 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { } let wasInitialPathUpdate = !hasReceivedInitialPathUpdate guard wasInitialPathUpdate else { - if pathChanged { - pathUpdateContinuation.yield(()) - } + // `observedPath()` may have read the monitor's newer snapshot + // before this callback reached the actor, so wake on every + // post-initial callback rather than relying on path equality. + pathUpdateContinuation.yield(()) return } hasReceivedInitialPathUpdate = true From 4120fe68e8f982f6f947d10638f9520797a0f5af Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:13:03 -0700 Subject: [PATCH 7/8] fix(ios): own Tailscale readiness in one deadline-bounded state machine CmxTailscaleRouteReadiness (new, generic over the platform interface token) is now the single owner of path state: sequence-ordered ingest, content-based proof generations, cancellation-safe waiters, and one injected-Clock readiness deadline racing the proof loop. It replaces the initial-path waiters, the single-consumer AsyncStream, and the direct monitor.currentPath reads, and removes the unbounded prepare loop that made the transport's three-attempt retry dead code. A tunnel that never becomes provable now fails within the 10s readiness deadline as tailscaleAuthorizationUnavailable, which pairing already maps to the actionable Tailscale guidance, instead of hanging until the generic RPC request timeout. Behavior tests cover scan-before-first-path-callback, bring-up mid-wait, deadline expiry, cancellation while parked, stale observation ordering, and duplicate-callback generation stability. Co-Authored-By: Claude Fable 5 --- .../MobileShellComposite.swift | 5 +- .../CmxNetworkByteTransport.swift | 8 +- .../CmxPreparingTailscaleByteTransport.swift | 63 ++-- .../CmxTailscaleRouteAuthority.swift | 240 ++++--------- .../CmxTailscaleRouteProof.swift | 2 +- .../CmxTailscaleRouteReadiness.swift | 262 ++++++++++++++ .../CmxTailscaleRouteProofTests.swift | 23 +- .../CmxTailscaleRouteReadinessTests.swift | 334 ++++++++++++++++++ .../ManualClock.swift | 100 ++++++ 9 files changed, 816 insertions(+), 221 deletions(-) create mode 100644 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift create mode 100644 Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift create mode 100644 Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ManualClock.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 5348c77d6f68..df3b2f0be13e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -4429,7 +4429,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } catch { MobileDebugLog.shared.append( - "pairing.qr_decode.failed error=\(String(describing: error))" + // Type-only: a decode error can embed the scanned payload. + "pairing.qr_decode.failed error_type=\(type(of: error))" ) if case MobileSyncPairingPayloadError.loopbackRouteRejected = error { // A scanned/pasted code that only points back at the Mac @@ -4544,7 +4545,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // specific error; record without overwriting that message. recordFailureForCurrentConnectionError(phase: "connect", category: noThrowFailure) MobileDebugLog.shared.append( - "pairing.attempt.failed_without_throw category=\(noThrowFailure)" + "pairing.attempt.failed_without_throw category=\(noThrowFailure.map(String.init(describing:)) ?? "nil")" ) return .failed } catch is CancellationError { diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift index a2a635d9f000..a179f644bc49 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift @@ -331,9 +331,11 @@ public actor CmxNetworkByteTransport: CmxByteTransport { guard !isTerminal else { return } - MobileDebugLog.shared.append( - "tailscale.connection.ready path=\(Self.pathSummary(connection.currentPath)) revision=\(tailscalePathRevision)" - ) + if tailscaleBinding != nil { + MobileDebugLog.shared.append( + "tailscale.connection.ready path=\(Self.pathSummary(connection.currentPath)) revision=\(tailscalePathRevision)" + ) + } do { try await validateTailscaleAuthorizationForCurrentPath() } catch { diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift index c8980e352345..b1f97b4b3559 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift @@ -11,8 +11,12 @@ nonisolated private let tailscalePreparationLog = Logger( /// Defers the actor-isolated route proof until `connect()` while preserving the /// synchronous transport-factory contract. The proven interface is set on /// `NWParameters` before Network.framework starts the connection. +/// +/// Waiting for tunnel readiness (including the retry-on-path-update loop and +/// its deadline) is owned entirely by the route authority; this transport +/// makes exactly one `prepare` call and maps its failure to the transport +/// error the pairing classifier turns into actionable Tailscale guidance. actor CmxPreparingTailscaleByteTransport: CmxByteTransport { - private static let maximumPreparationAttempts = 3 private let request: CmxByteTransportRequest private let tailscaleRouteAuthority: any CmxTailscaleRouteAuthorizing private let maximumReceiveLength: Int @@ -91,48 +95,27 @@ actor CmxPreparingTailscaleByteTransport: CmxByteTransport { let maximumReceiveLength = maximumReceiveLength let connectTimeoutNanoseconds = connectTimeoutNanoseconds task = Task { - for attempt in 1...Self.maximumPreparationAttempts { + do { + let prepared = try await authority.prepare(request: request) + try Task.checkCancellation() + return try CmxNetworkByteTransport( + request: request, + preparedTailscaleRoute: prepared, + tailscaleRouteAuthority: authority, + maximumReceiveLength: maximumReceiveLength, + connectTimeoutNanoseconds: connectTimeoutNanoseconds + ) + } catch is CancellationError { + throw CancellationError() + } catch { MobileDebugLog.shared.append( - "tailscale.prepare.attempt number=\(attempt)/\(Self.maximumPreparationAttempts)" + "tailscale.prepare.failed error=\(String(describing: error))" + ) + tailscalePreparationLog.error( + "Tailscale preparation failed: \(String(describing: error), privacy: .public)" ) - do { - let prepared = try await authority.prepare(request: request) - try Task.checkCancellation() - return try CmxNetworkByteTransport( - request: request, - preparedTailscaleRoute: prepared, - tailscaleRouteAuthority: authority, - maximumReceiveLength: maximumReceiveLength, - connectTimeoutNanoseconds: connectTimeoutNanoseconds - ) - } catch is CancellationError { - throw CancellationError() - } catch let error as CmxTailscaleRouteProofError - where error.isTransientReadinessFailure - { - MobileDebugLog.shared.append( - "tailscale.prepare.transient_failure attempt=\(attempt) error=\(String(describing: error))" - ) - tailscalePreparationLog.debug( - "Tailscale preparation attempt \(attempt, privacy: .public)/\(Self.maximumPreparationAttempts, privacy: .public) deferred: \(String(describing: error), privacy: .public)" - ) - guard attempt < Self.maximumPreparationAttempts else { - throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable - } - // Wait for a real NWPathMonitor transition. Repeating - // immediately only re-reads the same pre-Tailscale path. - await authority.waitForNextPathUpdate() - } catch { - MobileDebugLog.shared.append( - "tailscale.prepare.permanent_failure error=\(String(describing: error))" - ) - tailscalePreparationLog.error( - "Tailscale preparation failed: \(String(describing: error), privacy: .public)" - ) - throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable - } + throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable } - throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable } preparationTask = task } diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift index be72dc10ff1c..ee4718c9daf1 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift @@ -3,6 +3,7 @@ import CmuxMobileDiagnostics import Darwin import Foundation @preconcurrency import Network +import os struct CmxPreparedTailscaleRoute: Sendable { let proof: CmxTailscaleRouteProof @@ -11,46 +12,51 @@ struct CmxPreparedTailscaleRoute: Sendable { protocol CmxTailscaleRouteAuthorizing: Sendable { func prepare(request: CmxByteTransportRequest) async throws -> CmxPreparedTailscaleRoute - func waitForNextPathUpdate() async func validate(proof: CmxTailscaleRouteProof, connectionPath: NWPath) async throws } -extension CmxTailscaleRouteAuthorizing { - func waitForNextPathUpdate() async {} -} - -actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { - private struct PathState: Sendable { - var generation: UInt64 = 0 - var path: NWPath? - } - - private struct ObservedPath: Sendable { - let generation: UInt64 - let path: NWPath - } +/// Platform wiring for ``CmxTailscaleRouteReadiness``: converts every +/// `NWPathMonitor` callback into a sequence-stamped observation and forwards +/// it to the readiness actor, which owns all waiting, retry, and deadline +/// state. `currentPath` is never treated as authoritative on its own; the +/// monitor's startup snapshot only reaches the readiness actor as an +/// observation like any other, so preparation gates on real callbacks. +final class CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing, Sendable { + /// How long preparation waits for the tunnel to become provable. Chosen to + /// cover Tailscale's tunnel bring-up after a QR scan while staying under + /// the RPC layer's whole-request deadline, so an unusable tunnel surfaces + /// as the actionable Tailscale failure rather than a generic timeout. + static let defaultReadinessDeadline: Duration = .seconds(10) - private var pathState = PathState() - private var hasReceivedInitialPathUpdate = false - private var initialPathWaiters: [CheckedContinuation] = [] - private let pathUpdateContinuation: AsyncStream.Continuation - private let pathUpdateStream: AsyncStream private let monitor: NWPathMonitor - - init() { + private let readiness: CmxTailscaleRouteReadiness + private let observationSequence: OSAllocatedUnfairLock + + init( + clock: any Clock = ContinuousClock(), + readinessDeadline: Duration = CmxSystemTailscaleRouteAuthority.defaultReadinessDeadline + ) { + let readiness = CmxTailscaleRouteReadiness( + clock: clock, + readinessDeadline: readinessDeadline + ) + let sequence = OSAllocatedUnfairLock(initialState: 0) let monitor = NWPathMonitor() + self.readiness = readiness + self.observationSequence = sequence self.monitor = monitor - let pathUpdates = AsyncStream.makeStream( - bufferingPolicy: .bufferingNewest(1) - ) - self.pathUpdateContinuation = pathUpdates.continuation - self.pathUpdateStream = pathUpdates.stream - monitor.pathUpdateHandler = { [weak self] path in - guard let self else { return } - Task { await self.observe(path) } + // The handler captures only the readiness actor and the sequence lock + // (not self), so the authority can deinit and cancel the monitor. + monitor.pathUpdateHandler = { path in + let observation = Self.observation( + sequence: Self.nextSequence(sequence), + path: path + ) + Task { await readiness.ingest(observation) } } - // Network.framework requires a callback queue. The callback immediately - // enters this actor, which is the sole owner of mutable path state. + // Network.framework requires a callback queue; the serial queue orders + // sequence stamping with capture, and the readiness actor drops any + // observation that arrives out of order after the hop. monitor.start( queue: DispatchQueue( label: "dev.cmux.mobile.tailscale-route-authority" @@ -66,155 +72,51 @@ actor CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing { MobileDebugLog.shared.append( "tailscale.prepare.begin route=\(request.route.kind.rawValue)" ) - // `currentPath` is not authoritative until NWPathMonitor has delivered - // its first callback. Pairing can begin immediately after the scanner - // returns, so gate the first proof on that callback instead of treating - // the monitor's startup snapshot as a real Tailscale outage. - await waitForInitialPathUpdate() - while true { - let observed = observedPath() - let snapshot = Self.authoritySnapshot( - generation: observed.generation, - path: observed.path - ) - MobileDebugLog.shared.append(Self.logLine("tailscale.prepare.snapshot", snapshot: snapshot)) - let proof: CmxTailscaleRouteProof - do { - proof = try CmxTailscaleRouteProofValidator().prepare( - request: request, - snapshot: snapshot - ) - } catch let error as CmxTailscaleRouteProofError - where error.isTransientReadinessFailure - { - MobileDebugLog.shared.append( - "tailscale.prepare.proof_failed error=\(String(describing: error)); waiting_for_path_update=true" - ) - await waitForNextPathUpdate() - continue - } catch { - MobileDebugLog.shared.append( - "tailscale.prepare.proof_failed error=\(String(describing: error))" - ) - throw error - } - guard let interface = observed.path.availableInterfaces.first(where: { - $0.name == proof.interface.name && $0.index == proof.interface.index - }) else { - MobileDebugLog.shared.append( - "tailscale.prepare.interface_failed proof_interface=\(proof.interface.name):\(proof.interface.index) snapshot_interfaces=\(Self.interfaceNames(observed.path)); waiting_for_path_update=true" - ) - await waitForNextPathUpdate() - continue - } - MobileDebugLog.shared.append( - "tailscale.prepare.success interface=\(proof.interface.name):\(proof.interface.index) generation=\(proof.generation)" - ) - return CmxPreparedTailscaleRoute(proof: proof, requiredInterface: interface) - } - } - - func waitForNextPathUpdate() async { - var iterator = pathUpdateStream.makeAsyncIterator() - _ = await iterator.next() + let ready = try await readiness.prepare(request: request) + return CmxPreparedTailscaleRoute( + proof: ready.proof, + requiredInterface: ready.interface + ) } - func validate(proof: CmxTailscaleRouteProof, connectionPath: NWPath) throws { - let observed = observedPath() - let authoritySnapshot = Self.authoritySnapshot( - generation: observed.generation, - path: observed.path + func validate(proof: CmxTailscaleRouteProof, connectionPath: NWPath) async throws { + // `currentPath` can advance before the queued callback task lands on + // the readiness actor. Ingest a fresh capture first so the write + // boundary can never validate against a stale observation. + await readiness.ingest( + Self.observation( + sequence: Self.nextSequence(observationSequence), + path: monitor.currentPath + ) ) - let connectionSnapshot = Self.connectionPathSnapshot(connectionPath) - try CmxTailscaleRouteProofValidator().validate( + try await readiness.validate( proof: proof, - authoritySnapshot: authoritySnapshot, - connectionPath: connectionSnapshot + connectionPath: Self.connectionPathSnapshot(connectionPath) ) } - private func observedPath() -> ObservedPath { - let currentPath = monitor.currentPath - // `currentPath` can advance before the callback reaches this actor. - // Record that transition synchronously so a write cannot reuse the old - // authority generation during that callback window. - if pathState.path == nil || pathState.path != currentPath { - pathState.generation = Self.nextGeneration(after: pathState.generation) - pathState.path = currentPath - } - return ObservedPath(generation: pathState.generation, path: currentPath) - } - - private func observe(_ path: NWPath) { - let pathChanged = pathState.path != path - if pathChanged { - pathState.generation = Self.nextGeneration(after: pathState.generation) - pathState.path = path - let snapshot = Self.authoritySnapshot( - generation: pathState.generation, - path: path - ) - MobileDebugLog.shared.append(Self.logLine("tailscale.path_update", snapshot: snapshot)) - } - let wasInitialPathUpdate = !hasReceivedInitialPathUpdate - guard wasInitialPathUpdate else { - // `observedPath()` may have read the monitor's newer snapshot - // before this callback reached the actor, so wake on every - // post-initial callback rather than relying on path equality. - pathUpdateContinuation.yield(()) - return - } - hasReceivedInitialPathUpdate = true - let waiters = initialPathWaiters - initialPathWaiters.removeAll(keepingCapacity: false) - waiters.forEach { $0.resume() } - } - - private func waitForInitialPathUpdate() async { - guard !hasReceivedInitialPathUpdate else { return } - await withCheckedContinuation { continuation in - if hasReceivedInitialPathUpdate { - continuation.resume() - } else { - initialPathWaiters.append(continuation) - } + private static func nextSequence( + _ lock: OSAllocatedUnfairLock + ) -> UInt64 { + lock.withLock { sequence in + sequence += 1 + return sequence } } - private static func logLine( - _ prefix: String, - snapshot: CmxTailscaleAuthoritySnapshot - ) -> String { - let interfaces = snapshot.systemInterfaces.map { interface in - "\(interface.identity.name):\(interface.identity.index),up=\(interface.isUp),running=\(interface.isRunning),tailnet_addrs=\(interface.tailnetAddresses.count)" - }.sorted().joined(separator: ";") - return "\(prefix) generation=\(snapshot.generation) path_satisfied=\(snapshot.pathSatisfied) available_interfaces=\(interfaceNames(snapshot.availableInterfaces)) system_interfaces=\(interfaces)" - } - - private static func interfaceNames(_ interfaces: Set) -> String { - interfaces.map { "\($0.name):\($0.index)" }.sorted().joined(separator: ",") - } - - private static func interfaceNames(_ path: NWPath) -> String { - interfaceNames(Set(path.availableInterfaces.map { - CmxNetworkInterfaceIdentity(name: $0.name, index: $0.index) - })) - } - - private static func nextGeneration(after generation: UInt64) -> UInt64 { - generation == .max ? 1 : generation + 1 - } - - private static func authoritySnapshot( - generation: UInt64, + private static func observation( + sequence: UInt64, path: NWPath - ) -> CmxTailscaleAuthoritySnapshot { - CmxTailscaleAuthoritySnapshot( - generation: generation, + ) -> CmxTailscalePathObservation { + CmxTailscalePathObservation( + sequence: sequence, pathSatisfied: path.status == .satisfied, - availableInterfaces: Set(path.availableInterfaces.map { - CmxNetworkInterfaceIdentity(name: $0.name, index: $0.index) - }), + interfaces: Dictionary( + path.availableInterfaces.map { + (CmxNetworkInterfaceIdentity(name: $0.name, index: $0.index), $0) + }, + uniquingKeysWith: { first, _ in first } + ), systemInterfaces: CmxSystemInterfaceSnapshotReader().read() ) } diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift index b6c6254ee489..5392b4b96fa2 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift @@ -122,7 +122,7 @@ struct CmxNetworkInterfaceIdentity: Hashable, Sendable { let index: Int } -struct CmxTailscaleInterfaceSnapshot: Equatable, Sendable { +struct CmxTailscaleInterfaceSnapshot: Hashable, Sendable { let identity: CmxNetworkInterfaceIdentity let isUp: Bool let isRunning: Bool diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift new file mode 100644 index 000000000000..e3120d292ac1 --- /dev/null +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift @@ -0,0 +1,262 @@ +internal import CMUXMobileCore +import CmuxMobileDiagnostics +import Foundation + +/// Route preparation ended because the tunnel never became usable, not because +/// the request itself was invalid. +enum CmxTailscaleReadinessError: Error, Equatable, Sendable { + /// The readiness deadline elapsed before an observation satisfied the + /// route proof. Carries the last transient proof failure (nil when no + /// path observation arrived at all) so diagnostics can say why. + case deadlineExpired(lastFailure: CmxTailscaleRouteProofError?) +} + +/// One path observation captured atomically at the platform boundary: the +/// proof-facing content plus the platform interface token the dialer needs +/// for each interface identity. `sequence` records capture order so a +/// reordered hop onto the readiness actor cannot regress to an older state. +struct CmxTailscalePathObservation: Sendable { + let sequence: UInt64 + let pathSatisfied: Bool + let interfaces: [CmxNetworkInterfaceIdentity: Interface] + let systemInterfaces: [CmxTailscaleInterfaceSnapshot] +} + +/// A proof plus the platform interface token it was proven against, taken +/// from the same observation so the pair can never disagree. +struct CmxTailscaleReadyRoute: Sendable { + let proof: CmxTailscaleRouteProof + let interface: Interface +} + +/// The single owner of Tailscale route readiness: the latest path observation, +/// its proof generation, and every waiter parked on the next observation. +/// +/// `prepare(request:)` retries transient proof failures on each new +/// observation and gives up only when the injected clock's readiness deadline +/// elapses, so a QR scanned while the tunnel is still coming up succeeds the +/// moment the tunnel interface appears instead of failing on the startup +/// snapshot. Waiters resume with `CancellationError` when their task is +/// cancelled, so a closed transport can never strand a suspended preparation. +/// +/// Generic over the platform interface token so the lifecycle is testable +/// without constructing `NWPath`/`NWInterface`. +actor CmxTailscaleRouteReadiness { + private struct Latest { + let generation: UInt64 + let observation: CmxTailscalePathObservation + } + + private let clock: any Clock + private let readinessDeadline: Duration + private var latest: Latest? + private var waiters: [UUID: CheckedContinuation] = [:] + /// Waiter IDs whose cancellation handler ran before registration; the + /// registration throws instead of suspending. + private var cancelledWaiterIDs: Set = [] + private var lastTransientFailures: [UUID: CmxTailscaleRouteProofError] = [:] + + init(clock: any Clock, readinessDeadline: Duration) { + self.clock = clock + self.readinessDeadline = readinessDeadline + } + + /// Test-only visibility into how many callers are parked on the next + /// observation. + var pendingWaiterCount: Int { waiters.count } + + /// Records a newer observation and wakes every parked waiter. Observations + /// older than the latest one are dropped: capture order is authoritative, + /// not arrival order. The proof generation advances only when proof-facing + /// content changes, so duplicate monitor callbacks cannot invalidate a + /// proven route. + func ingest(_ observation: CmxTailscalePathObservation) { + if let latest, observation.sequence <= latest.observation.sequence { + return + } + let generation: UInt64 + if let latest { + generation = Self.contentMatches(latest.observation, observation) + ? latest.generation + : Self.nextGeneration(after: latest.generation) + } else { + generation = 1 + } + let changed = latest.map { $0.generation != generation } ?? true + latest = Latest(generation: generation, observation: observation) + if changed, let snapshot = latestSnapshot { + MobileDebugLog.shared.append( + Self.logLine("tailscale.path_update", snapshot: snapshot) + ) + } + let continuations = waiters.values + waiters.removeAll(keepingCapacity: true) + for continuation in continuations { + continuation.resume() + } + } + + /// Returns a proven route bound to the observation it was proven against. + /// Waits through transient readiness failures until the deadline; throws + /// `CmxTailscaleReadinessError.deadlineExpired` when the tunnel never + /// becomes usable, non-transient proof failures immediately, and + /// `CancellationError` when the caller's task is cancelled. + func prepare( + request: CmxByteTransportRequest + ) async throws -> CmxTailscaleReadyRoute { + let attemptID = UUID() + defer { lastTransientFailures[attemptID] = nil } + return try await withThrowingTaskGroup( + of: CmxTailscaleReadyRoute?.self + ) { group in + group.addTask { + try await self.readyRoute(request: request, attemptID: attemptID) + } + group.addTask { () -> CmxTailscaleReadyRoute? in + try await self.clock.sleep( + for: self.readinessDeadline, + tolerance: nil + ) + return nil + } + defer { group.cancelAll() } + guard let first = try await group.next(), let route = first else { + throw CmxTailscaleReadinessError.deadlineExpired( + lastFailure: lastTransientFailures[attemptID] + ) + } + return route + } + } + + /// Validates a proof against the latest observation. The caller supplies + /// the connection-path snapshot; the system authority refreshes the latest + /// observation first so this cannot pass against a stale one. + func validate( + proof: CmxTailscaleRouteProof, + connectionPath: CmxTailscaleConnectionPathSnapshot + ) throws { + guard let snapshot = latestSnapshot else { + throw CmxTailscaleRouteProofError.pathUnavailable + } + try CmxTailscaleRouteProofValidator().validate( + proof: proof, + authoritySnapshot: snapshot, + connectionPath: connectionPath + ) + } + + private func readyRoute( + request: CmxByteTransportRequest, + attemptID: UUID + ) async throws -> CmxTailscaleReadyRoute? { + while true { + try Task.checkCancellation() + let observedSequence = latest?.observation.sequence + if let latest { + do { + guard let snapshot = latestSnapshot else { + throw CmxTailscaleRouteProofError.pathUnavailable + } + let proof = try CmxTailscaleRouteProofValidator().prepare( + request: request, + snapshot: snapshot + ) + guard let interface = latest.observation.interfaces[proof.interface] else { + // The validator only selects identities present in the + // observation, so this cannot fire; classified transient + // to fail toward the deadline instead of aborting. + throw CmxTailscaleRouteProofError.tailscaleInterfaceUnavailable + } + MobileDebugLog.shared.append( + "tailscale.prepare.success interface=\(proof.interface.name):\(proof.interface.index) generation=\(proof.generation)" + ) + return CmxTailscaleReadyRoute(proof: proof, interface: interface) + } catch let error as CmxTailscaleRouteProofError + where error.isTransientReadinessFailure + { + lastTransientFailures[attemptID] = error + MobileDebugLog.shared.append( + "tailscale.prepare.waiting reason=\(String(describing: error)) generation=\(latest.generation)" + ) + } + } else { + MobileDebugLog.shared.append( + "tailscale.prepare.waiting reason=no_path_observation" + ) + } + try await nextObservation(after: observedSequence) + } + } + + /// Parks until an observation newer than `sequence` has been ingested. + /// Re-checks inside the continuation body so an observation landing + /// between the caller's read and registration can never be slept through, + /// and resumes with `CancellationError` when the waiting task is + /// cancelled. + private func nextObservation(after sequence: UInt64?) async throws { + let waiterID = UUID() + defer { cancelledWaiterIDs.remove(waiterID) } + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if cancelledWaiterIDs.remove(waiterID) != nil { + continuation.resume(throwing: CancellationError()) + return + } + if latest?.observation.sequence != sequence { + continuation.resume() + return + } + waiters[waiterID] = continuation + } + } onCancel: { + Task { await self.cancelWaiter(id: waiterID) } + } + } + + private func cancelWaiter(id: UUID) { + if let continuation = waiters.removeValue(forKey: id) { + continuation.resume(throwing: CancellationError()) + } else { + cancelledWaiterIDs.insert(id) + } + } + + private var latestSnapshot: CmxTailscaleAuthoritySnapshot? { + guard let latest else { return nil } + return CmxTailscaleAuthoritySnapshot( + generation: latest.generation, + pathSatisfied: latest.observation.pathSatisfied, + availableInterfaces: Set(latest.observation.interfaces.keys), + systemInterfaces: latest.observation.systemInterfaces + ) + } + + private static func contentMatches( + _ lhs: CmxTailscalePathObservation, + _ rhs: CmxTailscalePathObservation + ) -> Bool { + lhs.pathSatisfied == rhs.pathSatisfied + && Set(lhs.interfaces.keys) == Set(rhs.interfaces.keys) + && Set(lhs.systemInterfaces) == Set(rhs.systemInterfaces) + } + + private static func nextGeneration(after generation: UInt64) -> UInt64 { + generation == .max ? 1 : generation + 1 + } + + private static func logLine( + _ prefix: String, + snapshot: CmxTailscaleAuthoritySnapshot + ) -> String { + let interfaces = snapshot.systemInterfaces.map { interface in + "\(interface.identity.name):\(interface.identity.index),up=\(interface.isUp),running=\(interface.isRunning),tailnet_addrs=\(interface.tailnetAddresses.count)" + }.sorted().joined(separator: ";") + let available = snapshot.availableInterfaces + .map { "\($0.name):\($0.index)" } + .sorted() + .joined(separator: ",") + return "\(prefix) generation=\(snapshot.generation) path_satisfied=\(snapshot.pathSatisfied) available_interfaces=\(available) system_interfaces=\(interfaces)" + } +} diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift index b092f6b31a3f..f51ca0f9e4a7 100644 --- a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift +++ b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift @@ -6,12 +6,23 @@ private let tailscaleInterface = CmxNetworkInterfaceIdentity(name: "utun4", inde @Suite struct CmxTailscaleRouteProofTests { @Test func classifiesOnlyLiveTunnelStateChangesAsTransientReadinessFailures() { - #expect(CmxTailscaleRouteProofError.pathUnavailable.isTransientReadinessFailure) - #expect(CmxTailscaleRouteProofError.tailscaleInterfaceUnavailable.isTransientReadinessFailure) - #expect(CmxTailscaleRouteProofError.ambiguousTailscaleInterfaces.isTransientReadinessFailure) - #expect(CmxTailscaleRouteProofError.routeGenerationChanged.isTransientReadinessFailure) - #expect(!CmxTailscaleRouteProofError.authorizationEvidenceMismatch.isTransientReadinessFailure) - #expect(!CmxTailscaleRouteProofError.peerOutsideTailscaleRange.isTransientReadinessFailure) + let transient: [CmxTailscaleRouteProofError] = [ + .pathUnavailable, .tailscaleInterfaceUnavailable, + .ambiguousTailscaleInterfaces, .routeGenerationChanged, + .interfaceChanged, .connectionPathUnavailable, + ] + let terminal: [CmxTailscaleRouteProofError] = [ + .unsupportedRouteKind, .unsupportedAuthorizationMode, + .authorizationEvidenceMismatch, .unsupportedEndpoint, + .nonNumericPeer, .peerOutsideTailscaleRange, .peerIsLocalDevice, + .localEndpointMismatch, .remoteEndpointMismatch, .remotePortMismatch, + ] + for error in transient { + #expect(error.isTransientReadinessFailure, "\(error) must retry within the readiness deadline") + } + for error in terminal { + #expect(!error.isTransientReadinessFailure, "\(error) must fail the attempt immediately") + } } @Test func rejectsGenericBearerAndMismatchedLegacyEvidence() throws { diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift new file mode 100644 index 000000000000..a14183473820 --- /dev/null +++ b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift @@ -0,0 +1,334 @@ +import CMUXMobileCore +import Foundation +import Network +import Testing +@testable import CmuxMobileTransport + +private let tailscaleIdentity = CmxNetworkInterfaceIdentity(name: "utun4", index: 22) +private let wifiIdentity = CmxNetworkInterfaceIdentity(name: "en0", index: 15) + +/// Behavior coverage for the QR-scan-during-Tailscale-bring-up race: route +/// preparation must park on real path observations, succeed the moment the +/// tunnel becomes provable, fail with the readiness error (not a hang) when +/// it never does, and unblock immediately on cancellation. +@Suite struct CmxTailscaleRouteReadinessTests { + @Test func firstScanWaitsThroughTunnelBringUpThenSucceeds() async throws { + let clock = ManualClock() + let readiness = CmxTailscaleRouteReadiness( + clock: clock, + readinessDeadline: .seconds(10) + ) + let request = try tailscaleRequest() + + let prepared = Task { try await readiness.prepare(request: request) } + // The scan happened before NWPathMonitor delivered anything: prepare + // must park rather than judge the monitor's startup snapshot. + try await waitForParkedWaiter(readiness) + + // First real observation: Wi-Fi only, tunnel not up yet. Prepare must + // keep waiting instead of failing the pairing attempt. + await readiness.ingest(wifiOnlyObservation(sequence: 1)) + try await waitForParkedWaiter(readiness) + + // Tunnel appears: the parked preparation completes with a proof bound + // to the tunnel observation and its platform interface token. + await readiness.ingest(tailscaleObservation(sequence: 2)) + let route = try await prepared.value + #expect(route.proof.interface == tailscaleIdentity) + #expect(route.interface == "token-utun4") + #expect(route.proof.generation == 2) + + try await readiness.validate( + proof: route.proof, + connectionPath: connectionPath() + ) + } + + @Test func deadlineExpiryThrowsReadinessErrorWithLastFailure() async throws { + let clock = ManualClock() + let readiness = CmxTailscaleRouteReadiness( + clock: clock, + readinessDeadline: .seconds(10) + ) + await readiness.ingest(wifiOnlyObservation(sequence: 1)) + + let prepared = Task { try await readiness.prepare(request: try tailscaleRequest()) } + try await waitForParkedWaiter(readiness) + try await waitForSleeper(clock) + + clock.advance(by: .seconds(10)) + await #expect(throws: CmxTailscaleReadinessError.deadlineExpired( + lastFailure: .tailscaleInterfaceUnavailable + )) { + try await prepared.value + } + } + + @Test func deadlineExpiryWithoutAnyObservationReportsNoPath() async throws { + let clock = ManualClock() + let readiness = CmxTailscaleRouteReadiness( + clock: clock, + readinessDeadline: .seconds(10) + ) + + let prepared = Task { try await readiness.prepare(request: try tailscaleRequest()) } + try await waitForParkedWaiter(readiness) + try await waitForSleeper(clock) + + clock.advance(by: .seconds(10)) + await #expect(throws: CmxTailscaleReadinessError.deadlineExpired( + lastFailure: nil + )) { + try await prepared.value + } + } + + @Test func cancellationUnblocksParkedPreparation() async throws { + let clock = ManualClock() + let readiness = CmxTailscaleRouteReadiness( + clock: clock, + readinessDeadline: .seconds(10) + ) + + let prepared = Task { try await readiness.prepare(request: try tailscaleRequest()) } + try await waitForParkedWaiter(readiness) + + prepared.cancel() + await #expect(throws: CancellationError.self) { + try await prepared.value + } + } + + @Test func nonTransientProofFailureFailsImmediately() async throws { + let clock = ManualClock() + let readiness = CmxTailscaleRouteReadiness( + clock: clock, + readinessDeadline: .seconds(10) + ) + await readiness.ingest(tailscaleObservation(sequence: 1)) + + let mismatchedEvidence = try CmxLegacyTailscaleAuthorizationEvidence( + macDeviceID: "mac-2", + host: "100.71.210.41", + port: 58_465 + ) + let request = try tailscaleRequest( + authorizationMode: .legacyTailscaleBearer(mismatchedEvidence) + ) + + await #expect(throws: CmxTailscaleRouteProofError.authorizationEvidenceMismatch) { + try await readiness.prepare(request: request) + } + } + + @Test func staleObservationCannotRegressReadyState() async throws { + let clock = ManualClock() + let readiness = CmxTailscaleRouteReadiness( + clock: clock, + readinessDeadline: .seconds(10) + ) + // Capture order is authoritative: the tunnel observation was captured + // after the Wi-Fi-only one, so the late-arriving stale capture must + // not knock readiness back down. + await readiness.ingest(tailscaleObservation(sequence: 2)) + await readiness.ingest(wifiOnlyObservation(sequence: 1)) + + let route = try await readiness.prepare(request: tailscaleRequest()) + #expect(route.proof.interface == tailscaleIdentity) + try await readiness.validate( + proof: route.proof, + connectionPath: connectionPath() + ) + } + + @Test func duplicateObservationKeepsProofGenerationValid() async throws { + let clock = ManualClock() + let readiness = CmxTailscaleRouteReadiness( + clock: clock, + readinessDeadline: .seconds(10) + ) + await readiness.ingest(tailscaleObservation(sequence: 1)) + let route = try await readiness.prepare(request: tailscaleRequest()) + + // NWPathMonitor delivers duplicate callbacks; identical content must + // not invalidate an already-proven route. + await readiness.ingest(tailscaleObservation(sequence: 2)) + try await readiness.validate( + proof: route.proof, + connectionPath: connectionPath() + ) + + // A real content change must invalidate it. + var interfaces = [tailscaleIdentity: "token-utun4", wifiIdentity: "token-en0"] + let second = CmxNetworkInterfaceIdentity(name: "utun5", index: 23) + interfaces[second] = "token-utun5" + await readiness.ingest(CmxTailscalePathObservation( + sequence: 3, + pathSatisfied: true, + interfaces: interfaces, + systemInterfaces: [ + interfaceSnapshot( + identity: tailscaleIdentity, + addresses: ["100.70.231.80"] + ), + interfaceSnapshot(identity: second, addresses: ["100.68.1.2"]), + ] + )) + await #expect(throws: CmxTailscaleRouteProofError.routeGenerationChanged) { + try await readiness.validate( + proof: route.proof, + connectionPath: connectionPath() + ) + } + } + + @Test func closingPreparingTransportCancelsParkedPreparation() async throws { + let authority = ParkedTailscaleAuthority() + let transport = CmxPreparingTailscaleByteTransport( + request: try tailscaleRequest(), + tailscaleRouteAuthority: authority, + maximumReceiveLength: 64 * 1024, + connectTimeoutNanoseconds: 1_000_000_000 + ) + + let connect = Task { try await transport.connect() } + // Deterministic ordering: close only after preparation actually parked. + try await waitUntil { await authority.parkedCount == 1 } + + await transport.close() + await #expect(throws: CancellationError.self) { + try await connect.value + } + } +} + +/// Parks `prepare` forever (cancellation-aware) so tests can prove that +/// closing the transport unblocks a suspended preparation. +private actor ParkedTailscaleAuthority: CmxTailscaleRouteAuthorizing { + private var continuations: [UUID: CheckedContinuation] = [:] + private(set) var parkedCount = 0 + + func prepare(request _: CmxByteTransportRequest) async throws -> CmxPreparedTailscaleRoute { + let id = UUID() + parkedCount += 1 + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + continuations[id] = continuation + } + } onCancel: { + Task { await self.cancel(id: id) } + } + throw CancellationError() + } + + func validate( + proof _: CmxTailscaleRouteProof, + connectionPath _: NWPath + ) throws { + throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable + } + + private func cancel(id: UUID) { + continuations.removeValue(forKey: id)? + .resume(throwing: CancellationError()) + } +} + +private func waitForParkedWaiter( + _ readiness: CmxTailscaleRouteReadiness, + count: Int = 1 +) async throws { + try await waitUntil { await readiness.pendingWaiterCount == count } +} + +private func waitForSleeper(_ clock: ManualClock) async throws { + try await waitUntil { clock.sleeperCount == 1 } +} + +private func waitUntil( + _ condition: () async -> Bool +) async throws { + for _ in 0 ..< 4000 { + if await condition() { return } + try await Task.sleep(for: .milliseconds(1)) + } + Issue.record("condition never became true") +} + +private func tailscaleRequest( + authorizationMode: CmxTransportAuthorizationMode? = nil +) throws -> CmxByteTransportRequest { + let host = "100.71.210.41" + let port = 58_465 + let mode = try authorizationMode ?? .legacyTailscaleBearer( + CmxLegacyTailscaleAuthorizationEvidence( + macDeviceID: "mac-1", + host: host, + port: port + ) + ) + return CmxByteTransportRequest( + route: try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: host, port: port) + ), + expectedPeerDeviceID: "mac-1", + authorizationMode: mode + ) +} + +private func wifiOnlyObservation(sequence: UInt64) -> CmxTailscalePathObservation { + CmxTailscalePathObservation( + sequence: sequence, + pathSatisfied: true, + interfaces: [wifiIdentity: "token-en0"], + systemInterfaces: [ + interfaceSnapshot(identity: wifiIdentity, addresses: ["192.168.1.10"]) + ] + ) +} + +private func tailscaleObservation(sequence: UInt64) -> CmxTailscalePathObservation { + CmxTailscalePathObservation( + sequence: sequence, + pathSatisfied: true, + interfaces: [ + wifiIdentity: "token-en0", + tailscaleIdentity: "token-utun4", + ], + systemInterfaces: [ + interfaceSnapshot(identity: wifiIdentity, addresses: ["192.168.1.10"]), + interfaceSnapshot( + identity: tailscaleIdentity, + addresses: ["100.70.231.80", "fd7a:115c:a1e0::6c36:e750"] + ), + ] + ) +} + +private func interfaceSnapshot( + identity: CmxNetworkInterfaceIdentity, + addresses: [String] +) -> CmxTailscaleInterfaceSnapshot { + CmxTailscaleInterfaceSnapshot( + identity: identity, + isUp: true, + isRunning: true, + addresses: Set(addresses.compactMap(CmxTailscaleIPAddress.init)) + ) +} + +private func connectionPath( + remoteAddress: String = "100.71.210.41", + remotePort: Int = 58_465 +) -> CmxTailscaleConnectionPathSnapshot { + CmxTailscaleConnectionPathSnapshot( + isSatisfied: true, + availableInterfaces: [tailscaleIdentity], + localAddress: CmxTailscaleIPAddress("100.70.231.80"), + remoteAddress: CmxTailscaleIPAddress(remoteAddress), + remotePort: remotePort + ) +} diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ManualClock.swift b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ManualClock.swift new file mode 100644 index 000000000000..e7018c1139a8 --- /dev/null +++ b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ManualClock.swift @@ -0,0 +1,100 @@ +import Foundation + +/// A hand-advanced `Clock` so the Tailscale readiness deadline is testable +/// without wall-clock sleeps. `advance(by:)` resumes every sleeper whose +/// deadline has been reached; sleepers registering at or before the current +/// instant resume immediately; cancelled sleepers resume by throwing +/// `CancellationError`, matching `ContinuousClock` semantics. +final class ManualClock: Clock, @unchecked Sendable { + struct Instant: InstantProtocol { + var offset: Duration + + func advanced(by duration: Duration) -> Instant { + Instant(offset: offset + duration) + } + + func duration(to other: Instant) -> Duration { + other.offset - offset + } + + static func < (lhs: Instant, rhs: Instant) -> Bool { + lhs.offset < rhs.offset + } + } + + private struct Sleeper { + let id: UUID + let deadline: Instant + let continuation: UnsafeContinuation + } + + private let lock = NSLock() + private var _now = Instant(offset: .zero) + private var sleepers: [Sleeper] = [] + /// Sleep IDs whose cancellation handler ran before the continuation was + /// registered; the registration throws instead of suspending. + private var preCancelledIDs: Set = [] + + var now: Instant { + lock.lock() + defer { lock.unlock() } + return _now + } + + var minimumResolution: Duration { .zero } + + /// The number of tasks currently suspended in `sleep`. + var sleeperCount: Int { + lock.lock() + defer { lock.unlock() } + return sleepers.count + } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withUnsafeThrowingContinuation { (continuation: UnsafeContinuation) in + lock.lock() + if preCancelledIDs.remove(id) != nil { + lock.unlock() + continuation.resume(throwing: CancellationError()) + return + } + if deadline <= _now { + lock.unlock() + continuation.resume() + return + } + sleepers.append(Sleeper(id: id, deadline: deadline, continuation: continuation)) + lock.unlock() + } + } onCancel: { + cancelSleeper(id: id) + } + } + + func advance(by duration: Duration) { + lock.lock() + _now = _now.advanced(by: duration) + let due = sleepers + .filter { $0.deadline <= _now } + .sorted { $0.deadline < $1.deadline } + sleepers.removeAll { $0.deadline <= _now } + lock.unlock() + for sleeper in due { + sleeper.continuation.resume() + } + } + + private func cancelSleeper(id: UUID) { + lock.lock() + guard let index = sleepers.firstIndex(where: { $0.id == id }) else { + preCancelledIDs.insert(id) + lock.unlock() + return + } + let sleeper = sleepers.remove(at: index) + lock.unlock() + sleeper.continuation.resume(throwing: CancellationError()) + } +} From 2071d98e44a0f91782c6eb27543f6f36638140fb Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:52:30 -0700 Subject: [PATCH 8/8] fix(ios): stop pre-ready path updates from killing Tailscale dials Device logs from the first-scan repro showed every fresh Tailscale dial dying 6ms in: NWConnection's first path update arrives before the socket binds its local endpoint, the full endpoint validation ran anyway, and localEndpointMismatch (terminal) failed the transport before ready. The bug predates this branch; retries only succeeded when ready won the callback race. Validation is now phase-aware: a pathUpdate-phase check asserts route-level facts only (generation, satisfied path, proven interface still present) since endpoint facts do not exist yet, while ready and every write boundary keep the full established-phase check including local/remote endpoints. Regression test covers the exact still-connecting path shape from the device log. Co-Authored-By: Claude Fable 5 --- .../CmxNetworkByteTransport.swift | 21 +++-- .../CmxTailscaleRouteAuthority.swift | 15 +++- .../CmxTailscaleRouteProof.swift | 20 ++++- .../CmxTailscaleRouteReadiness.swift | 6 +- ...orkByteTransportFactorySecurityTests.swift | 3 +- .../CmxTailscaleRouteProofTests.swift | 82 +++++++++++++++++-- .../CmxTailscaleRouteReadinessTests.swift | 3 +- 7 files changed, 129 insertions(+), 21 deletions(-) diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift index a179f644bc49..6f4bd26440bf 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift @@ -692,7 +692,10 @@ public actor CmxNetworkByteTransport: CmxByteTransport { "tailscale.connection.path_update revision=\(tailscalePathRevision) path=\(Self.pathSummary(path))" ) do { - try await validateTailscaleAuthorization(path: path) + // This callback can fire before the connection binds its local + // endpoint, so only route-level facts exist here; the endpoint + // facts are asserted at ready and at every write boundary. + try await validateTailscaleAuthorization(path: path, phase: .pathUpdate) } catch { MobileDebugLog.shared.append( "tailscale.connection.path_validation_failed error=\(String(describing: error)) revision=\(tailscalePathRevision) path=\(Self.pathSummary(path))" @@ -708,7 +711,7 @@ public actor CmxNetworkByteTransport: CmxByteTransport { throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable } let revision = tailscalePathRevision - try await validateTailscaleAuthorization(path: path) + try await validateTailscaleAuthorization(path: path, phase: .established) // The authority call yields this actor. Reject any connection-path // update that interleaved before the synchronous send boundary. guard revision == tailscalePathRevision else { @@ -716,10 +719,13 @@ public actor CmxNetworkByteTransport: CmxByteTransport { } } - private func validateTailscaleAuthorization(path: NWPath) async throws { + private func validateTailscaleAuthorization( + path: NWPath, + phase: CmxTailscaleRouteValidationPhase + ) async throws { guard let binding = tailscaleBinding else { return } MobileDebugLog.shared.append( - "tailscale.authorization.validate_begin path=\(Self.pathSummary(path)) revision=\(tailscalePathRevision)" + "tailscale.authorization.validate_begin phase=\(phase) path=\(Self.pathSummary(path)) revision=\(tailscalePathRevision)" ) guard !tailscaleAuthorizationInvalidated, binding.request == binding.preparedRoute.proof.request, @@ -732,12 +738,13 @@ public actor CmxNetworkByteTransport: CmxByteTransport { do { try await binding.authority.validate( proof: binding.preparedRoute.proof, - connectionPath: path + connectionPath: path, + phase: phase ) - MobileDebugLog.shared.append("tailscale.authorization.validate_success") + MobileDebugLog.shared.append("tailscale.authorization.validate_success phase=\(phase)") } catch { MobileDebugLog.shared.append( - "tailscale.authorization.validate_failed underlying=\(String(describing: error)) path=\(Self.pathSummary(path))" + "tailscale.authorization.validate_failed underlying=\(String(describing: error)) phase=\(phase) path=\(Self.pathSummary(path))" ) throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable } diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift index ee4718c9daf1..454d0290f8e3 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift @@ -12,7 +12,11 @@ struct CmxPreparedTailscaleRoute: Sendable { protocol CmxTailscaleRouteAuthorizing: Sendable { func prepare(request: CmxByteTransportRequest) async throws -> CmxPreparedTailscaleRoute - func validate(proof: CmxTailscaleRouteProof, connectionPath: NWPath) async throws + func validate( + proof: CmxTailscaleRouteProof, + connectionPath: NWPath, + phase: CmxTailscaleRouteValidationPhase + ) async throws } /// Platform wiring for ``CmxTailscaleRouteReadiness``: converts every @@ -79,7 +83,11 @@ final class CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing, Send ) } - func validate(proof: CmxTailscaleRouteProof, connectionPath: NWPath) async throws { + func validate( + proof: CmxTailscaleRouteProof, + connectionPath: NWPath, + phase: CmxTailscaleRouteValidationPhase + ) async throws { // `currentPath` can advance before the queued callback task lands on // the readiness actor. Ingest a fresh capture first so the write // boundary can never validate against a stale observation. @@ -91,7 +99,8 @@ final class CmxSystemTailscaleRouteAuthority: CmxTailscaleRouteAuthorizing, Send ) try await readiness.validate( proof: proof, - connectionPath: Self.connectionPathSnapshot(connectionPath) + connectionPath: Self.connectionPathSnapshot(connectionPath), + phase: phase ) } diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift index 5392b4b96fa2..41074ba8adf4 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift @@ -157,6 +157,20 @@ struct CmxTailscaleRouteProof: Equatable, Sendable { let generation: UInt64 } +/// Which facts a connection-path validation may assert. A connection's path +/// update can arrive before the socket has bound its local endpoint, so +/// endpoint-level facts (local/remote address, remote port) only exist once +/// the connection is established; validating them earlier misreads a +/// still-connecting dial as an endpoint substitution and kills pairing. +enum CmxTailscaleRouteValidationPhase: Sendable { + /// A path update on a connection that may not be established yet: + /// validate route-level facts only. + case pathUpdate + /// The connection reported ready, or a write is about to begin: the + /// path must carry the proven endpoints. + case established +} + struct CmxTailscaleRouteProofValidator { func prepare( request: CmxByteTransportRequest, @@ -220,7 +234,8 @@ struct CmxTailscaleRouteProofValidator { func validate( proof: CmxTailscaleRouteProof, authoritySnapshot: CmxTailscaleAuthoritySnapshot, - connectionPath: CmxTailscaleConnectionPathSnapshot + connectionPath: CmxTailscaleConnectionPathSnapshot, + phase: CmxTailscaleRouteValidationPhase ) throws { guard authoritySnapshot.generation == proof.generation else { throw CmxTailscaleRouteProofError.routeGenerationChanged @@ -239,6 +254,9 @@ struct CmxTailscaleRouteProofValidator { connectionPath.availableInterfaces.contains(proof.interface) else { throw CmxTailscaleRouteProofError.connectionPathUnavailable } + // A still-connecting dial has no bound endpoints yet; those facts are + // asserted at ready and at every write boundary instead. + guard phase == .established else { return } guard let localAddress = connectionPath.localAddress, proof.selfAddresses.contains(localAddress) else { throw CmxTailscaleRouteProofError.localEndpointMismatch diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift index e3120d292ac1..0e03552e4da4 100644 --- a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift +++ b/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift @@ -134,7 +134,8 @@ actor CmxTailscaleRouteReadiness { /// observation first so this cannot pass against a stale one. func validate( proof: CmxTailscaleRouteProof, - connectionPath: CmxTailscaleConnectionPathSnapshot + connectionPath: CmxTailscaleConnectionPathSnapshot, + phase: CmxTailscaleRouteValidationPhase = .established ) throws { guard let snapshot = latestSnapshot else { throw CmxTailscaleRouteProofError.pathUnavailable @@ -142,7 +143,8 @@ actor CmxTailscaleRouteReadiness { try CmxTailscaleRouteProofValidator().validate( proof: proof, authoritySnapshot: snapshot, - connectionPath: connectionPath + connectionPath: connectionPath, + phase: phase ) } diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportFactorySecurityTests.swift b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportFactorySecurityTests.swift index 45f3f647caac..ab058eab5abe 100644 --- a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportFactorySecurityTests.swift +++ b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportFactorySecurityTests.swift @@ -19,7 +19,8 @@ private actor RejectingTailscaleAuthority: CmxTailscaleRouteAuthorizing { func validate( proof _: CmxTailscaleRouteProof, - connectionPath _: NWPath + connectionPath _: NWPath, + phase _: CmxTailscaleRouteValidationPhase ) throws { throw RejectingTailscaleAuthorityError.rejected } diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift index f51ca0f9e4a7..479911a0fa54 100644 --- a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift +++ b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift @@ -100,7 +100,8 @@ private let tailscaleInterface = CmxNetworkInterfaceIdentity(name: "utun4", inde try CmxTailscaleRouteProofValidator().validate( proof: ipv4Proof, authoritySnapshot: snapshot, - connectionPath: connectionPath() + connectionPath: connectionPath(), + phase: .established ) let ipv6 = try tailscaleRequest(host: "fd7a:115c:a1e0::1234") @@ -111,7 +112,8 @@ private let tailscaleInterface = CmxNetworkInterfaceIdentity(name: "utun4", inde try CmxTailscaleRouteProofValidator().validate( proof: ipv6Proof, authoritySnapshot: snapshot, - connectionPath: connectionPath(remoteAddress: "fd7a:115c:a1e0::1234") + connectionPath: connectionPath(remoteAddress: "fd7a:115c:a1e0::1234"), + phase: .established ) #expect(ipv4Proof.interface == tailscaleInterface) @@ -131,7 +133,8 @@ private let tailscaleInterface = CmxNetworkInterfaceIdentity(name: "utun4", inde try CmxTailscaleRouteProofValidator().validate( proof: proof, authoritySnapshot: authoritySnapshot(generation: 42), - connectionPath: connectionPath() + connectionPath: connectionPath(), + phase: .established ) } #expect(throws: CmxTailscaleRouteProofError.connectionPathUnavailable) { @@ -144,21 +147,88 @@ private let tailscaleInterface = CmxNetworkInterfaceIdentity(name: "utun4", inde localAddress: CmxTailscaleIPAddress("100.70.231.80"), remoteAddress: CmxTailscaleIPAddress("100.71.210.41"), remotePort: 58_465 - ) + ), + phase: .established ) } #expect(throws: CmxTailscaleRouteProofError.remoteEndpointMismatch) { try CmxTailscaleRouteProofValidator().validate( proof: proof, authoritySnapshot: snapshot, - connectionPath: connectionPath(remoteAddress: "100.71.210.42") + connectionPath: connectionPath(remoteAddress: "100.71.210.42"), + phase: .established ) } #expect(throws: CmxTailscaleRouteProofError.remotePortMismatch) { try CmxTailscaleRouteProofValidator().validate( proof: proof, authoritySnapshot: snapshot, - connectionPath: connectionPath(remotePort: 58_466) + connectionPath: connectionPath(remotePort: 58_466), + phase: .established + ) + } + } + + /// Repro of the first-scan device failure: `NWConnection` delivers its + /// first path update before the socket binds a local endpoint, so a + /// still-connecting path carries the proven interface but no endpoints. + /// That update must pass route-level validation instead of misreading the + /// unbound endpoints as a substitution and killing the dial; the same + /// path must still fail the established phase, where endpoints are + /// mandatory. + @Test func preEstablishmentPathUpdateValidatesRouteFactsOnly() throws { + let request = try tailscaleRequest(host: "100.71.210.41") + let snapshot = authoritySnapshot(generation: 41) + let proof = try CmxTailscaleRouteProofValidator().prepare( + request: request, + snapshot: snapshot + ) + let stillConnecting = CmxTailscaleConnectionPathSnapshot( + isSatisfied: true, + availableInterfaces: [tailscaleInterface], + localAddress: nil, + remoteAddress: CmxTailscaleIPAddress("100.71.210.41"), + remotePort: 58_465 + ) + + try CmxTailscaleRouteProofValidator().validate( + proof: proof, + authoritySnapshot: snapshot, + connectionPath: stillConnecting, + phase: .pathUpdate + ) + #expect(throws: CmxTailscaleRouteProofError.localEndpointMismatch) { + try CmxTailscaleRouteProofValidator().validate( + proof: proof, + authoritySnapshot: snapshot, + connectionPath: stillConnecting, + phase: .established + ) + } + + // Route-level facts still gate the path-update phase: a path that + // lost the proven interface fails even while connecting. + let wrongInterface = CmxTailscaleConnectionPathSnapshot( + isSatisfied: true, + availableInterfaces: [CmxNetworkInterfaceIdentity(name: "utun5", index: 23)], + localAddress: nil, + remoteAddress: nil, + remotePort: nil + ) + #expect(throws: CmxTailscaleRouteProofError.connectionPathUnavailable) { + try CmxTailscaleRouteProofValidator().validate( + proof: proof, + authoritySnapshot: snapshot, + connectionPath: wrongInterface, + phase: .pathUpdate + ) + } + #expect(throws: CmxTailscaleRouteProofError.routeGenerationChanged) { + try CmxTailscaleRouteProofValidator().validate( + proof: proof, + authoritySnapshot: authoritySnapshot(generation: 42), + connectionPath: stillConnecting, + phase: .pathUpdate ) } } diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift index a14183473820..58fdac4acf92 100644 --- a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift +++ b/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift @@ -224,7 +224,8 @@ private actor ParkedTailscaleAuthority: CmxTailscaleRouteAuthorizing { func validate( proof _: CmxTailscaleRouteProof, - connectionPath _: NWPath + connectionPath _: NWPath, + phase _: CmxTailscaleRouteValidationPhase ) throws { throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable }