diff --git a/FptnVPN/Cpp/Wrappers/WebScoketClientBridge.swift b/FptnVPN/Cpp/Wrappers/WebScoketClientBridge.swift index 27d9f55..c6ea56a 100644 --- a/FptnVPN/Cpp/Wrappers/WebScoketClientBridge.swift +++ b/FptnVPN/Cpp/Wrappers/WebScoketClientBridge.swift @@ -56,6 +56,11 @@ final class WebsocketClientBridge { private let disconnectedCallback: DisconnectionCallback private let ipAssignedCallback: IPAssignedCallback + // PR2: one-shot lifecycle latch. + private enum Lifecycle { case created, started, stopped } + private let lifecycleLock = NSLock() + private var lifecycle: Lifecycle = .created + // MARK: - Init / deinit init( @@ -125,16 +130,24 @@ final class WebsocketClientBridge { // MARK: - Control + // PR2: lifecycle-locked start/stop. @discardableResult func start() -> Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + guard lifecycle == .created else { return false } let ok = clientBridge.start() + lifecycle = ok ? .started : .stopped logger.debug("WebSocket start → \(ok)") return ok } - // PR1C: stop accepts an origin for disconnect classification. @discardableResult func stop(origin: WebsocketStopOrigin = .swiftTunnelStop) -> Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + guard lifecycle != .stopped else { return false } + lifecycle = .stopped let ok = clientBridge.stop(origin.rawValue) logger.debug("WebSocket stop → \(ok) origin=\(origin)") return ok diff --git a/FptnVPN/Services/VPNService.swift b/FptnVPN/Services/VPNService.swift index 8b34b4f..d6e5758 100644 --- a/FptnVPN/Services/VPNService.swift +++ b/FptnVPN/Services/VPNService.swift @@ -54,7 +54,8 @@ final class VPNService: ObservableObject { private var conflictDisconnects: Int = 0 private var remainingFallbackBudget: Int = 0 private var isUserInitiatedDisconnect: Bool = true - private var isFallbackReconnecting: Bool = false + // PR2: cancellable fallback reconnect task replaces the boolean guard. + private var fallbackReconnectTask: Task? private let tokenService = TokenService.shared private let serverSelectionService = ServerSelectionService.shared @@ -93,7 +94,7 @@ final class VPNService: ObservableObject { func disconnect() { let manager = packetTunnelProvider isUserInitiatedDisconnect = true - isFallbackReconnecting = false + cancelFallbackReconnect() connection.isConnected = false connection.isConnecting = false connection.isReconnecting = false @@ -557,6 +558,7 @@ final class VPNService: ObservableObject { switch status { case .connected: + cancelFallbackReconnect() connection.isConnected = true connection.isConnecting = false connection.isReconnecting = false @@ -570,6 +572,7 @@ final class VPNService: ObservableObject { let maxAttempts = SettingsService.shared.maxReconnectAttempts remainingFallbackBudget = maxAttempts == 0 ? Int.max : maxAttempts case .reasserting: + cancelFallbackReconnect() connection.isConnected = true connection.isConnecting = false connection.isReconnecting = true @@ -579,6 +582,7 @@ final class VPNService: ObservableObject { startTimer() stopSpeedMonitoring() case .connecting: + cancelFallbackReconnect() connection.isConnected = false connection.isConnecting = true connection.isReconnecting = false @@ -595,7 +599,7 @@ final class VPNService: ObservableObject { logLastDisconnectError(from: tunnelConnection) detectConflictAfterDisconnect() - // If it's an unexpected disconnect (connection drop) and fallback reconnect is enabled + // PR2: gated fallback reconnect with heartbeat check. if !isUserInitiatedDisconnect && SettingsService.shared.reconnectEnabled { triggerFallbackReconnect() } @@ -705,36 +709,94 @@ final class VPNService: ObservableObject { } } + // PR2: fallback reconnect decision. + private enum FallbackReconnectDecision { + case doNotReconnect + case reconnectNow + case recheckAfter(TimeInterval) + } + + private func cancelFallbackReconnect() { + fallbackReconnectTask?.cancel() + fallbackReconnectTask = nil + } + + // PR2: evaluate whether to reconnect, defer, or suppress. + private func evaluateFallbackDecision() -> FallbackReconnectDecision { + guard !isUserInitiatedDisconnect else { return .doNotReconnect } + guard SettingsService.shared.reconnectEnabled else { return .doNotReconnect } + + // Check provider heartbeat staleness. + let heartbeat = TunnelDiagnosticsStore.shared.readHeartbeat() + let heartbeatAge = heartbeat.flatMap { + TunnelDiagnosticsStore.ageSeconds(since: $0.timestamp) + } + + // Fresh heartbeat (< 45s): provider is alive and handling its + // own reconnect. Defer recheck until the heartbeat goes stale. + if let age = heartbeatAge, age < 45 { + return .recheckAfter(TimeInterval(45 - age)) + } + + // PR2: use typed fields, not lastEvent strings. + // If the provider performed a controlled stop, don't restart. + // Raw values match LocalStopInitiator: "app_disconnect", "system_stop". + if let initiator = heartbeat?.localStopInitiator, + initiator == "app_disconnect" || initiator == "system_stop" { + return .doNotReconnect + } + + return .reconnectNow + } + private func triggerFallbackReconnect() { - guard !isUserInitiatedDisconnect else { return } - guard !isFallbackReconnecting else { return } - - isFallbackReconnecting = true - - Task { @MainActor in - defer { isFallbackReconnecting = false } - + cancelFallbackReconnect() + + fallbackReconnectTask = Task { @MainActor in + // PR2: recheck loop — a fresh heartbeat defers, not + // permanently suppresses. Keep checking until stale. + decisionLoop: while !Task.isCancelled { + switch evaluateFallbackDecision() { + case .doNotReconnect: + return + case .reconnectNow: + break decisionLoop + case .recheckAfter(let seconds): + logger.info("Fallback reconnect deferred \(String(format: "%.0f", seconds))s (provider heartbeat fresh)") + try? await Task.sleep(for: .seconds(max(1, seconds))) + } + } + guard !Task.isCancelled else { return } + let delaySeconds = SettingsService.shared.reconnectDelay logger.info("Scheduling fallback reconnect after \(delaySeconds)s") try? await Task.sleep(for: .seconds(delaySeconds)) - + guard !Task.isCancelled else { return } guard !isUserInitiatedDisconnect else { return } - + + // PR2: recheck actual NE status after sleeping. + guard packetTunnelProvider?.connection.status == .disconnected else { + logger.info("Fallback reconnect cancelled: tunnel no longer disconnected") + return + } + + // PR2: fix budget off-by-one. Check BEFORE decrementing + // so a budget of N allows exactly N attempts. let maxAttempts = SettingsService.shared.maxReconnectAttempts if maxAttempts != 0 { - remainingFallbackBudget -= 1 if remainingFallbackBudget <= 0 { logger.warning("Reconnection budget exhausted. Stopping reconnection attempts.") connection.errorMessage = "All servers unreachable (attempts exhausted)" return } + remainingFallbackBudget -= 1 } - + logger.info("Triggering fallback reconnect attempt. Remaining budget: \(maxAttempts == 0 ? "infinite" : String(remainingFallbackBudget))") - + connection.isReconnecting = true connection.runtimeState = .starting - + await performConnect() } } diff --git a/FptnVPNTests/FptnVPNTests.swift b/FptnVPNTests/FptnVPNTests.swift index 0eb62e0..d48eb7a 100644 --- a/FptnVPNTests/FptnVPNTests.swift +++ b/FptnVPNTests/FptnVPNTests.swift @@ -307,7 +307,10 @@ struct FptnVPNTests { transportReceivedPackets: 12, websocketSendFailures: 0, memoryResidentBytes: 123, - memoryPhysFootprintBytes: 456 + memoryPhysFootprintBytes: 456, + localStopInitiator: nil, + nativeDisconnectCode: nil, + nativeStopOrigin: nil ) ) try #"{"timestamp":"signal-time-unavailable","signal":11,"signalName":"SIGSEGV","process":"FptnVPNTunnel"}"# @@ -493,6 +496,72 @@ struct FptnVPNTests { #expect(invalidPackets == 1) } + // PR2: disconnect code and stop origin enum mapping. + @Test func disconnectCodeAndStopOriginMapBridgeValues() { + #expect(WebsocketDisconnectCode(bridgeValue: 0) == .none) + #expect(WebsocketDisconnectCode(bridgeValue: 1) == .peerClosed) + #expect(WebsocketDisconnectCode(bridgeValue: 5) == .watchdog) + #expect(WebsocketDisconnectCode(bridgeValue: 6) == .localStop) + #expect(WebsocketDisconnectCode(bridgeValue: 99) == .unknown) + + #expect(WebsocketStopOrigin(bridgeValue: 0) == .none) + #expect(WebsocketStopOrigin(bridgeValue: 1) == .swiftTunnelStop) + #expect(WebsocketStopOrigin(bridgeValue: 2) == .swiftReconnect) + #expect(WebsocketStopOrigin(bridgeValue: 3) == .nativeFailure) + #expect(WebsocketStopOrigin(bridgeValue: 4) == .peer) + #expect(WebsocketStopOrigin(bridgeValue: 200) == .unknown) + } + + // PR2: heartbeat with typed stop fields decodes correctly. + @Test func heartbeatDecodesTypedStopFields() throws { + let root = try temporaryDiagnosticsDirectory() + let store = TunnelDiagnosticsStore(rootURL: root) + store.writeHeartbeat( + TunnelProviderHeartbeat( + timestamp: TunnelDiagnosticsStore.now(), + runtimeState: "connected", + isReasserting: false, + generation: 1, + reconnectAttempt: 0, + maxReconnectAttempts: 5, + pathSatisfied: true, + websocketStarted: true, + websocketRunning: true, + lastTransportError: nil, + lastStopReason: nil, + lastEvent: "telemetry", + lastInboundActivityAt: nil, + lastOutboundActivityAt: nil, + packetFlowReadPackets: 0, + packetFlowWritePackets: 0, + transportReceivedPackets: 0, + websocketSendFailures: 0, + memoryResidentBytes: nil, + memoryPhysFootprintBytes: nil, + localStopInitiator: "app_disconnect", + nativeDisconnectCode: 6, + nativeStopOrigin: 1 + ) + ) + let heartbeat = store.readHeartbeat() + #expect(heartbeat?.localStopInitiator == "app_disconnect") + #expect(heartbeat?.nativeDisconnectCode == 6) + #expect(heartbeat?.nativeStopOrigin == 1) + } + + // PR2: old heartbeat without typed fields still decodes (backward compat). + @Test func heartbeatDecodesWithoutTypedStopFields() throws { + let root = try temporaryDiagnosticsDirectory() + try #"{"timestamp":"2026-01-01T00:00:00Z","runtimeState":"connected","isReasserting":false,"generation":1,"reconnectAttempt":0,"maxReconnectAttempts":5,"pathSatisfied":true,"websocketStarted":true,"websocketRunning":true,"lastTransportError":null,"lastStopReason":null,"lastEvent":"telemetry","lastInboundActivityAt":null,"lastOutboundActivityAt":null,"packetFlowReadPackets":0,"packetFlowWritePackets":0,"transportReceivedPackets":0,"websocketSendFailures":0,"memoryResidentBytes":null,"memoryPhysFootprintBytes":null}"# + .write(to: root.appendingPathComponent("provider-heartbeat.json"), atomically: true, encoding: .utf8) + let store = TunnelDiagnosticsStore(rootURL: root) + let heartbeat = store.readHeartbeat() + #expect(heartbeat != nil) + #expect(heartbeat?.localStopInitiator == nil) + #expect(heartbeat?.nativeDisconnectCode == nil) + #expect(heartbeat?.nativeStopOrigin == nil) + } + private func temporaryDiagnosticsDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("FptnVPNTests-\(UUID().uuidString)", isDirectory: true) diff --git a/FptnVPNTunnel/Cpp/WebScoketClientBridge.swift b/FptnVPNTunnel/Cpp/WebScoketClientBridge.swift index 7a16292..5adb0f9 100644 --- a/FptnVPNTunnel/Cpp/WebScoketClientBridge.swift +++ b/FptnVPNTunnel/Cpp/WebScoketClientBridge.swift @@ -57,6 +57,12 @@ final class WebsocketClientBridge { private let ipAssignedCallback: IPAssignedCallback private let diagnosticsID = UUID().uuidString + // PR2: one-shot lifecycle latch. Once stop() wins, start() is + // permanently rejected. Prevents resurrection after shutdown. + private enum Lifecycle { case created, started, stopped } + private let lifecycleLock = NSLock() + private var lifecycle: Lifecycle = .created + // MARK: - Init / deinit init( @@ -136,17 +142,26 @@ final class WebsocketClientBridge { // MARK: - Control + // PR2: lifecycle-locked start. Rejected if already started or stopped. @discardableResult func start() -> Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + guard lifecycle == .created else { return false } let ok = clientBridge.start() + lifecycle = ok ? .started : .stopped logger.debug("WebSocket start → \(ok)") TunnelDiagnosticsStore.shared.recordProviderEvent(category: "bridge", message: "start id=\(diagnosticsID) ok=\(ok)") return ok } - // PR1C: stop accepts an origin for disconnect classification. + // PR2: lifecycle-locked stop. Once stopped, start() is permanently rejected. @discardableResult func stop(origin: WebsocketStopOrigin = .swiftTunnelStop) -> Bool { + lifecycleLock.lock() + defer { lifecycleLock.unlock() } + guard lifecycle != .stopped else { return false } + lifecycle = .stopped let ok = clientBridge.stop(origin.rawValue) logger.debug("WebSocket stop → \(ok) origin=\(origin)") TunnelDiagnosticsStore.shared.recordProviderEvent(category: "bridge", message: "stop id=\(diagnosticsID) ok=\(ok) origin=\(origin)") diff --git a/FptnVPNTunnel/PacketTunnelProvider.swift b/FptnVPNTunnel/PacketTunnelProvider.swift index 65dfc5c..51b814f 100644 --- a/FptnVPNTunnel/PacketTunnelProvider.swift +++ b/FptnVPNTunnel/PacketTunnelProvider.swift @@ -85,6 +85,23 @@ private struct TunnelRuntimeSnapshot: Codable { let nativeCallbackExitCount: Int64 let nativeCallbackByteCount: Int64 let nativeInPacketCallback: Bool + // PR2: all PR1A–1C native diagnostics for app-side visibility. + let nativeRequestedRcvbufBytes: Int + let nativeRequestedSndbufBytes: Int + let nativeEffectiveRcvbufBytes: Int + let nativeEffectiveSndbufBytes: Int + let nativeSocketBufferSetErrorCount: Int + let nativeLiveClients: Int + let nativeActiveReaderCoroutines: Int + let nativeActiveSenderCoroutines: Int + let nativeQueuedPackets: UInt64 + let nativeQueuedBytes: UInt64 + let nativeQueuedBytesPeak: UInt64 + let nativeQueueFullCount: UInt64 + let nativeDisconnectCode: UInt16 + let nativeStopOrigin: UInt16 + let nativeActiveOperations: UInt32 + let nativeStopCleanupCompleted: Bool } private struct TunnelConfiguration { @@ -170,6 +187,19 @@ private struct PacketCounters { var lastSendFailureWarningAt: Date? } +// PR2: explicit invariant violations, logged once per type. +enum ProviderInvariant: Hashable { + case multipleNativeClients + case incompleteNativeTeardown +} + +// PR2: process-lifetime read ownership token. Prevents cross-session +// generation collisions (generation resets to 0 on each startTunnel). +private struct PacketReadToken: Equatable { + let session: UInt64 + let generation: Int +} + final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { private let eventQueue = DispatchQueue(label: "net.mrmidi.FptnVPN.tunnel.events") private let stateLock = NSLock() @@ -206,6 +236,15 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { private var readBackpressureWorkItem: DispatchWorkItem? private var consecutiveSendFailureBatches = 0 + // PR2: invariant tracking (logged once per type). + private var loggedInvariantViolations: Set = [] + // PR2: process-lifetime session token + generation-based read ownership. + private var tunnelSession: UInt64 = 0 + private var pendingReadToken: PacketReadToken? + // PR2: final native status preserved before detaching a client. + private var lastNativeStatus: WebsocketClientStatus? + private var lastNativeStatusGeneration: Int? + private let runtimeReconnectDelayCapSeconds = 60 private let activityResumeLogThresholdSeconds = 60 private let sendFailureWarningIntervalSeconds: TimeInterval = 1 @@ -253,10 +292,15 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { localStopInitiator = nil shutdownRequested = false isNetworkPathSatisfied = true + // PR2: increment session token. Generation resets but session + // is process-lifetime unique, preventing cross-session collisions. + tunnelSession &+= 1 websocketGeneration = 0 didApplyNetworkSettings = false isReadLoopActive = false - isPacketReadPending = false + // PR2: do NOT reset isPacketReadPending here. An outstanding + // readPackets callback from the old session still exists. + // Only the owning callback may clear it via token check. reconnectAttempt = 0 lastTransportError = nil lastStopReason = nil @@ -273,6 +317,10 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { assignedIPv6 = nil appliedIPv4 = nil appliedIPv6 = nil + // PR2: reset per-session state. + loggedInvariantViolations.removeAll() + lastNativeStatus = nil + lastNativeStatusGeneration = nil stateLock.unlock() updateRuntimeState(.starting, reason: "startTunnel") @@ -294,7 +342,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { lastStopReasonRawValue = Int(reason.rawValue) lastStopReason = description isReadLoopActive = false - isPacketReadPending = false + // PR2: do NOT clear isPacketReadPending — the outstanding + // readPackets callback still exists and owns the slot. stateLock.unlock() updateRuntimeState(.stopping, reason: "stopTunnel(\(description))") @@ -343,7 +392,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { localStopInitiator = .appDisconnect shutdownRequested = true isReadLoopActive = false - isPacketReadPending = false stateLock.unlock() cancelPendingReconnect() cancelReadBackpressure() @@ -453,9 +501,20 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } ) - replaceWebSocketClient(with: client, stopCurrent: false) + // PR2: pass expectedGeneration so a stale replacement is + // rejected before detaching the current bridge. If rejected, + // do NOT call client.start() — the bridge was stopped. + guard replaceWebSocketClient( + with: client, + stopCurrent: true, + stopOrigin: .swiftReconnect, + expectedGeneration: generation + ) else { + logger.info("WebSocket replacement rejected (shutdown/stale) context=\(context) generation=\(generation)") + return + } guard client.start() else { - replaceWebSocketClient(with: nil, stopCurrent: false) + replaceWebSocketClient(with: nil, stopCurrent: false, expectedGeneration: generation) logger.error("WebSocket start failed context=\(context) generation=\(generation)") recordProviderEvent(category: "websocket", message: "start_failed context=\(context) generation=\(generation)", generation: generation) eventQueue.async { [weak self] in @@ -586,12 +645,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { return } + // PR2: read native status for numeric disconnect diagnostics. + let nativeStatus = currentWebSocketClient()?.status + let disconnectCodeStr = nativeStatus.map { "\($0.disconnectCode)" } ?? "-" + let stopOriginStr = nativeStatus.map { "\($0.stopOrigin)" } ?? "-" + let activeOpsStr = nativeStatus.map { "\($0.activeOperations)" } ?? "-" + logger.warning( - "Tunnel websocket disconnected generation=\(generation) was_connected=\(wasConnected) reason=\(reason) stop_initiator=\(stopInitiator?.rawValue ?? "-") \(activityDiagnosticsDescription())" + "Tunnel websocket disconnected generation=\(generation) was_connected=\(wasConnected) reason=\(reason) stop_initiator=\(stopInitiator?.rawValue ?? "-") disconnect_code=\(disconnectCodeStr) stop_origin=\(stopOriginStr) active_ops=\(activeOpsStr) \(activityDiagnosticsDescription())" ) recordProviderEvent( category: "websocket", - message: "transport_disconnected generation=\(generation) was_connected=\(wasConnected) reason=\(reason) stop_initiator=\(stopInitiator?.rawValue ?? "-")\(pathHandoffHint)", + message: "transport_disconnected generation=\(generation) was_connected=\(wasConnected) reason=\(reason) stop_initiator=\(stopInitiator?.rawValue ?? "-") disconnect_code=\(disconnectCodeStr) stop_origin=\(stopOriginStr) active_ops=\(activeOpsStr)\(pathHandoffHint)", generation: generation ) updateDiagnosticsHeartbeat(lastEvent: "transport_disconnected") @@ -887,20 +952,45 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } stateLock.lock() - guard !isPacketReadPending else { + // PR2: exactly one process-wide pending read. A stale token + // from an old session does not block — but only the owning + // callback may clear it. If a read is pending (any session), + // do not issue another. + guard pendingReadToken == nil else { stateLock.unlock() return } + let readToken = PacketReadToken(session: tunnelSession, generation: websocketGeneration) + pendingReadToken = readToken isPacketReadPending = true stateLock.unlock() packetFlow.readPackets { [weak self] packets, _ in guard let self else { return } + // PR2: only the callback that owns the pending token may + // clear it. An old-session callback must not clobber + // ownership of a newer read. self.stateLock.lock() - self.isPacketReadPending = false + let ownsPendingSlot = self.pendingReadToken == readToken + if ownsPendingSlot { + self.isPacketReadPending = false + self.pendingReadToken = nil + } + let currentToken = PacketReadToken(session: self.tunnelSession, generation: self.websocketGeneration) self.stateLock.unlock() + guard ownsPendingSlot else { return } + + // PR2: stale generation within the same session — restart + // the current generation's read loop. + guard readToken == currentToken else { + self.eventQueue.async { [weak self] in + self?.startReadLoop() + } + return + } + guard self.shouldReadOutboundPackets() else { return } var queueFullPackets: Int64 = 0 @@ -995,19 +1085,95 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { return websocketGeneration } + // PR2: log an invariant violation once per type. + private func logInvariantOnce(_ invariant: ProviderInvariant) { + stateLock.lock() + let alreadyLogged = loggedInvariantViolations.contains(invariant) + if !alreadyLogged { + loggedInvariantViolations.insert(invariant) + } + stateLock.unlock() + + if !alreadyLogged { + logger.error("INVARIANT_VIOLATION: \(invariant)") + recordProviderEvent(category: "invariant", message: "violation \(invariant)") + } + } + + // PR2: replace the active bridge with stop-origin plumbing, + // final status preservation, and invariant checks. + // Order: validate generation → detach → stop/join → capture → expose. + // Returns false if the replacement was rejected (shutdown or + // generation mismatch). The caller must NOT start a rejected client. + @discardableResult private func replaceWebSocketClient( with newClient: WebsocketClientBridge?, - stopCurrent: Bool - ) { - var previousClient: WebsocketClientBridge? + stopCurrent: Bool, + stopOrigin: WebsocketStopOrigin = .swiftTunnelStop, + expectedGeneration: Int? = nil + ) -> Bool { + // PR2: validate generation BEFORE detaching the current bridge. + // A stale caller must never detach the current bridge. + stateLock.lock() + if let expectedGeneration, websocketGeneration != expectedGeneration { + stateLock.unlock() + if let newClient { _ = newClient.stop(origin: .swiftTunnelStop) } + return false + } + // PR2: reject new client installation during shutdown, but + // always allow removal (newClient == nil) so stopTunnel can + // detach and stop the active bridge. + if shutdownRequested, newClient != nil { + stateLock.unlock() + _ = newClient?.stop(origin: .swiftTunnelStop) + return false + } + let previousClient = wsClient + let generation = websocketGeneration + wsClient = nil + stateLock.unlock() + + // Stop and join the previous bridge. + if stopCurrent, let previousClient { + _ = previousClient.stop(origin: stopOrigin) + } + + // Capture final status AFTER teardown is complete. + if let previousClient { + let finalStatus = previousClient.status + stateLock.lock() + lastNativeStatus = finalStatus + lastNativeStatusGeneration = generation + stateLock.unlock() + + if finalStatus.activeOperations != 0 || !finalStatus.stopCleanupCompleted { + logInvariantOnce(.incompleteNativeTeardown) + } + if finalStatus.liveClients > 1 { + logInvariantOnce(.multipleNativeClients) + } + } + + // PR2: bridgeReplacementOverlap removed — the previous bridge + // is already stopped and joined before the new one is exposed, + // so both being non-nil is a normal replacement, not an overlap. + // liveClients > 1 (checked above via final status) is the + // meaningful overlap invariant. + + // Revalidate before exposing. stateLock.lock() - previousClient = wsClient - wsClient = newClient + let mayExpose = !shutdownRequested && websocketGeneration == generation + if mayExpose { + wsClient = newClient + } stateLock.unlock() - if stopCurrent { - _ = previousClient?.stop() + if !mayExpose, let newClient { + _ = newClient.stop(origin: .swiftTunnelStop) + return false } + + return true } private func recordPacketFlowRead( @@ -1476,37 +1642,93 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } private func currentSnapshot() -> TunnelRuntimeSnapshot { + // PR2: copy Swift fields and bridge reference under lock, + // then query native status OUTSIDE the lock to avoid holding + // stateLock during the C++ getStatus() call. + let client: WebsocketClientBridge? + let savedNativeStatus: WebsocketClientStatus? + let savedNativeGeneration: Int? + let state: TunnelRuntimeState + let reasserting: Bool + let reconnAttempt: Int + let maxReconn: Int + let transportError: String? + let stopReason: String? + let stopReasonRaw: Int? + let stopInitiator: String? + let inboundAt: Date? + let outboundAt: Date? + let readPackets: Int64 + let readBytes: Int64 + let recvPackets: Int64 + let recvBytes: Int64 + let writePackets: Int64 + let writeBytes: Int64 + let sendFailures: Int64 + let dns4: String? + let dns6: String? + let tun4: String? + let tun6: String? + let wsIdleTimeout: Int + stateLock.lock() - defer { stateLock.unlock() } + client = wsClient + savedNativeStatus = lastNativeStatus + savedNativeGeneration = lastNativeStatusGeneration + state = runtimeState + reasserting = runtimeState == .reasserting || runtimeState == .waitingForNetwork + reconnAttempt = reconnectAttempt + maxReconn = configuration?.maxReconnectAttempts ?? 0 + transportError = lastTransportError + stopReason = lastStopReason + stopReasonRaw = lastStopReasonRawValue + stopInitiator = localStopInitiator?.rawValue + inboundAt = counters.lastInboundActivityAt + outboundAt = counters.lastOutboundActivityAt + readPackets = counters.packetFlowReadPackets + readBytes = counters.packetFlowReadBytes + recvPackets = counters.transportReceivedPackets + recvBytes = counters.transportReceivedBytes + writePackets = counters.packetFlowWritePackets + writeBytes = counters.packetFlowWriteBytes + sendFailures = counters.websocketSendFailures + dns4 = configuration?.dnsIPv4 + dns6 = configuration?.dnsIPv6 + tun4 = configuration?.tunIPv4 + tun6 = configuration?.tunIPv6 + wsIdleTimeout = configuration?.websocketIdleTimeoutSeconds ?? 0 + stateLock.unlock() - let status = wsClient?.status + // Query native status outside the lock. + let status = client?.status ?? savedNativeStatus let memory = providerMemorySnapshot(status: status) + return TunnelRuntimeSnapshot( - runtimeState: runtimeState, - isReasserting: runtimeState == .reasserting || runtimeState == .waitingForNetwork, - reconnectAttempt: reconnectAttempt, - maxReconnectAttempts: configuration?.maxReconnectAttempts ?? 0, - lastTransportError: lastTransportError, - lastStopReason: lastStopReason, - lastStopReasonRawValue: lastStopReasonRawValue, - localStopInitiator: localStopInitiator?.rawValue, - lastInboundActivityAt: iso8601(counters.lastInboundActivityAt), - lastOutboundActivityAt: iso8601(counters.lastOutboundActivityAt), - packetFlowReadPackets: counters.packetFlowReadPackets, - packetFlowReadBytes: counters.packetFlowReadBytes, - transportReceivedPackets: counters.transportReceivedPackets, - transportReceivedBytes: counters.transportReceivedBytes, - packetFlowWritePackets: counters.packetFlowWritePackets, - packetFlowWriteBytes: counters.packetFlowWriteBytes, - websocketSendFailures: counters.websocketSendFailures, - dnsIPv4: configuration?.dnsIPv4, - dnsIPv6: configuration?.dnsIPv6, - tunnelIPv4: configuration?.tunIPv4, - tunnelIPv6: configuration?.tunIPv6, - ipv6Enabled: configuration?.dnsIPv6 != nil, + runtimeState: state, + isReasserting: reasserting, + reconnectAttempt: reconnAttempt, + maxReconnectAttempts: maxReconn, + lastTransportError: transportError, + lastStopReason: stopReason, + lastStopReasonRawValue: stopReasonRaw, + localStopInitiator: stopInitiator, + lastInboundActivityAt: iso8601(inboundAt), + lastOutboundActivityAt: iso8601(outboundAt), + packetFlowReadPackets: readPackets, + packetFlowReadBytes: readBytes, + transportReceivedPackets: recvPackets, + transportReceivedBytes: recvBytes, + packetFlowWritePackets: writePackets, + packetFlowWriteBytes: writeBytes, + websocketSendFailures: sendFailures, + dnsIPv4: dns4, + dnsIPv6: dns6, + tunnelIPv4: tun4, + tunnelIPv6: tun6, + ipv6Enabled: dns6 != nil, websocketRunning: status?.running ?? false, websocketStarted: status?.started ?? false, - websocketIdleTimeoutSeconds: status?.idleTimeoutSeconds ?? configuration?.websocketIdleTimeoutSeconds ?? 0, + websocketIdleTimeoutSeconds: status?.idleTimeoutSeconds ?? wsIdleTimeout, websocketLastError: status?.lastError, websocketLastDisconnectReason: status?.lastDisconnectReason, memoryResidentBytes: memory.residentBytes, @@ -1516,7 +1738,23 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { nativeCallbackEnterCount: status?.callbackEnterCount ?? 0, nativeCallbackExitCount: status?.callbackExitCount ?? 0, nativeCallbackByteCount: status?.callbackByteCount ?? 0, - nativeInPacketCallback: status?.inPacketCallback ?? false + nativeInPacketCallback: status?.inPacketCallback ?? false, + nativeRequestedRcvbufBytes: status?.requestedRcvbufBytes ?? 0, + nativeRequestedSndbufBytes: status?.requestedSndbufBytes ?? 0, + nativeEffectiveRcvbufBytes: status?.effectiveRcvbufBytes ?? 0, + nativeEffectiveSndbufBytes: status?.effectiveSndbufBytes ?? 0, + nativeSocketBufferSetErrorCount: status?.socketBufferSetErrorCount ?? 0, + nativeLiveClients: status?.liveClients ?? 0, + nativeActiveReaderCoroutines: status?.activeReaderCoroutines ?? 0, + nativeActiveSenderCoroutines: status?.activeSenderCoroutines ?? 0, + nativeQueuedPackets: status?.queuedPackets ?? 0, + nativeQueuedBytes: status?.queuedBytes ?? 0, + nativeQueuedBytesPeak: status?.queuedBytesPeak ?? 0, + nativeQueueFullCount: status?.queueFullCount ?? 0, + nativeDisconnectCode: status?.disconnectCode.rawValue ?? 0, + nativeStopOrigin: status?.stopOrigin.rawValue ?? 0, + nativeActiveOperations: status?.activeOperations ?? 0, + nativeStopCleanupCompleted: status?.stopCleanupCompleted ?? false ) } @@ -1591,7 +1829,10 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { transportReceivedPackets: snapshot.transportReceivedPackets, websocketSendFailures: snapshot.websocketSendFailures, memoryResidentBytes: snapshot.memoryResidentBytes, - memoryPhysFootprintBytes: snapshot.memoryPhysFootprintBytes + memoryPhysFootprintBytes: snapshot.memoryPhysFootprintBytes, + localStopInitiator: snapshot.localStopInitiator, + nativeDisconnectCode: snapshot.nativeDisconnectCode, + nativeStopOrigin: snapshot.nativeStopOrigin ) ) } diff --git a/SharedTunnelRuntime/TunnelDiagnosticsStore.swift b/SharedTunnelRuntime/TunnelDiagnosticsStore.swift index 7b6ddf4..884a4e8 100644 --- a/SharedTunnelRuntime/TunnelDiagnosticsStore.swift +++ b/SharedTunnelRuntime/TunnelDiagnosticsStore.swift @@ -33,6 +33,11 @@ struct TunnelProviderHeartbeat: Codable, Equatable, Sendable { let websocketSendFailures: Int64 let memoryResidentBytes: UInt64? let memoryPhysFootprintBytes: UInt64? + // PR2: typed fields for fallback reconnect gating. + // Optional so old persisted heartbeats still decode. + let localStopInitiator: String? + let nativeDisconnectCode: UInt16? + let nativeStopOrigin: UInt16? } enum TunnelMemoryPressureLevel: String, Codable, Sendable {