diff --git a/FptnVPN/Cpp/FptnVPN-Bridging-Header.h b/FptnVPN/Cpp/FptnVPN-Bridging-Header.h index 19fe6be..90f0777 100644 --- a/FptnVPN/Cpp/FptnVPN-Bridging-Header.h +++ b/FptnVPN/Cpp/FptnVPN-Bridging-Header.h @@ -9,5 +9,6 @@ Distributed under the MIT License (https://opensource.org/licenses/MIT) #include #include +#include "FptnTunnelDiagnostics.h" #endif // FPTN_VPN_BRIDGING_HEADER diff --git a/FptnVPNTunnel/Cpp/FptnVPNTunnel-Bridging-Header.h b/FptnVPNTunnel/Cpp/FptnVPNTunnel-Bridging-Header.h index 5cc3c56..0086407 100644 --- a/FptnVPNTunnel/Cpp/FptnVPNTunnel-Bridging-Header.h +++ b/FptnVPNTunnel/Cpp/FptnVPNTunnel-Bridging-Header.h @@ -2,5 +2,6 @@ #define FPTN_VPN_TUNNEL_BRIDGING_HEADER #include +#include "FptnTunnelDiagnostics.h" #endif // FPTN_VPN_TUNNEL_BRIDGING_HEADER diff --git a/FptnVPNTunnel/PacketTunnelProvider.swift b/FptnVPNTunnel/PacketTunnelProvider.swift index 51b814f..5c0b112 100644 --- a/FptnVPNTunnel/PacketTunnelProvider.swift +++ b/FptnVPNTunnel/PacketTunnelProvider.swift @@ -35,6 +35,19 @@ private enum TunnelRuntimeState: String, Codable { case waitingForNetwork case stopping case failed + + // PR3: stable numeric codes for binary snapshot. + var binaryCode: UInt32 { + switch self { + case .idle: return 0 + case .starting: return 1 + case .connected: return 2 + case .reasserting: return 3 + case .waitingForNetwork: return 4 + case .stopping: return 5 + case .failed: return 6 + } + } } private enum ReconnectScheduleOutcome { @@ -47,6 +60,15 @@ private enum LocalStopInitiator: String, Codable { case appDisconnect = "app_disconnect" case providerFailure = "provider_failure" case systemStop = "system_stop" + + // PR3: stable numeric codes for binary snapshot (not hashValue). + var binaryCode: UInt16 { + switch self { + case .appDisconnect: return 1 + case .providerFailure: return 2 + case .systemStop: return 3 + } + } } private struct TunnelRuntimeSnapshot: Codable { @@ -203,6 +225,17 @@ private struct PacketReadToken: Equatable { final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { private let eventQueue = DispatchQueue(label: "net.mrmidi.FptnVPN.tunnel.events") private let stateLock = NSLock() + // PR3: protects latestEventSequence and physicalFootprintPeakBytes + // which can be written from native callbacks on different threads. + private let diagnosticsLock = NSLock() + + // PR3: binary flight recorder + lifecycle snapshot store. + private var flightRecorder: TunnelFlightRecorder? + private var lifecycleStore: TunnelLifecycleSnapshotStore? + private var tunnelSessionToken: UInt64 = 0 + private var tunnelStartedMachTime: UInt64 = 0 + private var physicalFootprintPeakBytes: UInt64 = 0 + private var latestEventSequence: UInt64 = 0 private var wsClient: WebsocketClientBridge? private var configuration: TunnelConfiguration? @@ -256,7 +289,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { private let pathHandoffHintWindowSeconds: TimeInterval = 10 deinit { - recordProviderEvent(category: "lifecycle", message: "PacketTunnelProvider deinit") + recordProviderEvent(category: "lifecycle", message: "PacketTunnelProvider deinit", flightEvent: .tunnelStopped) updateDiagnosticsHeartbeat(lastEvent: "deinit") } @@ -266,8 +299,33 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { ) { bootstrapLogging() TunnelCrashSignalInstaller.installIfPossible() + + // PR3: initialize binary flight recorder + lifecycle snapshot store. + if flightRecorder == nil, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.net.mrmidi.FptnVPN") { + let diagDir = container.appendingPathComponent("diagnostics", isDirectory: true) + try? FileManager.default.createDirectory(at: diagDir, withIntermediateDirectories: true) + let ringPath = diagDir.appendingPathComponent("flight-ring.bin").path + let snapPath = diagDir.appendingPathComponent("lifecycle-snapshot.bin").path + flightRecorder = TunnelFlightRecorder(path: ringPath) + lifecycleStore = TunnelLifecycleSnapshotStore(path: snapPath) + let identity = ProviderProcessIdentity.shared + flightRecorder?.record(.processStarted, + value0: identity.pid, + value1: UInt64(Date().timeIntervalSince1970 * 1_000_000_000), + value2: identity.processSequence, + synchronize: true) + } + + tunnelSessionToken = UInt64.random(in: 1...UInt64.max) + tunnelStartedMachTime = mach_continuous_time() + physicalFootprintPeakBytes = 0 + latestEventSequence = 0 + flightRecorder?.recordForSession(.startTunnel, sessionToken: tunnelSessionToken, synchronize: true) + logger.info("PacketTunnelProvider startTunnel") - recordProviderEvent(category: "lifecycle", message: "startTunnel") + recordProviderEvent(category: "lifecycle", message: "startTunnel", flightEvent: .startTunnel) _ = options guard let config = protocolConfiguration as? NETunnelProviderProtocol, @@ -353,7 +411,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { recordProviderEvent( category: "lifecycle", message: "stopTunnel reason=\(reason.rawValue) (\(description)) initiator=\(initiator.rawValue)", - runtimeState: "stopping" + runtimeState: "stopping", + flightEvent: .stopTunnelEntered ) updateDiagnosticsHeartbeat(lastEvent: "stopTunnel") @@ -396,7 +455,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { cancelPendingReconnect() cancelReadBackpressure() logger.info("Marked local stop initiator via IPC: \(initiator); reconnect will be suppressed") - recordProviderEvent(category: "ipc", message: "prepare_stop initiator=\(initiator)") + recordProviderEvent(category: "ipc", message: "prepare_stop initiator=\(initiator)", flightEvent: .stopTunnelEntered) updateDiagnosticsHeartbeat(lastEvent: "prepare_stop") } completionHandler?(encodeResponse(TunnelControlResponse(ok: true, message: "stop_initiator_recorded"))) @@ -414,9 +473,10 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { category: "lifecycle", message: "sleep state=\(snapshot.runtimeState.rawValue)", runtimeState: snapshot.runtimeState.rawValue, - reconnectAttempt: snapshot.reconnectAttempt + reconnectAttempt: snapshot.reconnectAttempt, + flightEvent: .pathChanged ) - updateDiagnosticsHeartbeat(snapshot: snapshot, lastEvent: "sleep") + writeLifecycleSnapshot() completionHandler() } @@ -429,9 +489,10 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { category: "lifecycle", message: "wake state=\(snapshot.runtimeState.rawValue)", runtimeState: snapshot.runtimeState.rawValue, - reconnectAttempt: snapshot.reconnectAttempt + reconnectAttempt: snapshot.reconnectAttempt, + flightEvent: .pathChanged ) - updateDiagnosticsHeartbeat(snapshot: snapshot, lastEvent: "wake") + writeLifecycleSnapshot() if snapshot.runtimeState == .connected && !snapshot.websocketStarted { logger.warning("Tunnel wake detected connected state without a running websocket — connection likely lost during sleep") @@ -455,7 +516,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { if shutdownRequested { stateLock.unlock() logger.info("Skipping WebSocket start context=\(context) because shutdown was requested") - recordProviderEvent(category: "websocket", message: "skip_start context=\(context) shutdown=true") + recordProviderEvent(category: "websocket", message: "skip_start context=\(context) shutdown=true", flightEvent: .bridgeStopRequested) return } websocketGeneration += 1 @@ -475,14 +536,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { }, connectedCallback: { [weak self] in guard let self else { return } - self.recordProviderEvent(category: "websocket_callback", message: "connected_callback_enter generation=\(generation)", generation: generation) + self.recordProviderEvent(category: "websocket_callback", message: "connected_callback_enter generation=\(generation)", generation: generation, flightEvent: .bridgeConnected) self.eventQueue.async { [weak self] in self?.handleTransportConnected(generation: generation) } }, disconnectedCallback: { [weak self] wasConnected, reason in guard let self else { return } - self.recordProviderEvent(category: "websocket_callback", message: "disconnected_callback_enter generation=\(generation) was_connected=\(wasConnected) reason=\(reason)", generation: generation) + self.recordProviderEvent(category: "websocket_callback", message: "disconnected_callback_enter generation=\(generation) was_connected=\(wasConnected) reason=\(reason)", generation: generation, flightEvent: .transportDisconnected) self.eventQueue.async { [weak self] in self?.handleTransportDisconnected( generation: generation, @@ -516,7 +577,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard client.start() else { 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) + recordProviderEvent(category: "websocket", message: "start_failed context=\(context) generation=\(generation)", generation: generation, flightEvent: .bridgeStopRequested) eventQueue.async { [weak self] in self?.handleTransportDisconnected( generation: generation, @@ -528,7 +589,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } logger.debug("WebSocket start issued context=\(context) generation=\(generation)") - recordProviderEvent(category: "websocket", message: "start_issued context=\(context) generation=\(generation)", generation: generation) + recordProviderEvent(category: "websocket", message: "start_issued context=\(context) generation=\(generation)", generation: generation, flightEvent: .bridgeStartRequested) updateDiagnosticsHeartbeat(lastEvent: "websocket_start_issued") } @@ -554,24 +615,24 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { if isStaleCallback { logger.info("Ignoring stale websocket connected callback generation=\(generation)") - recordProviderEvent(category: "websocket", message: "ignore_stale_connected generation=\(generation)", generation: generation) + recordProviderEvent(category: "websocket", message: "ignore_stale_connected generation=\(generation)", generation: generation, flightEvent: .bridgeConnected) return } if shouldIgnoreForShutdown { logger.info("Ignoring websocket connected callback generation=\(generation) because shutdown was requested") - recordProviderEvent(category: "websocket", message: "ignore_connected_shutdown generation=\(generation)", generation: generation) + recordProviderEvent(category: "websocket", message: "ignore_connected_shutdown generation=\(generation)", generation: generation, flightEvent: .bridgeStopRequested) replaceWebSocketClient(with: nil, stopCurrent: true) return } guard let configuration else { logger.error("Transport connected without runtime configuration") - recordProviderEvent(category: "websocket", message: "connected_without_configuration generation=\(generation)", generation: generation) + recordProviderEvent(category: "websocket", message: "connected_without_configuration generation=\(generation)", generation: generation, flightEvent: .bridgeConnected) return } logger.info("Tunnel transport connected \(activityDiagnosticsDescription())") - recordProviderEvent(category: "websocket", message: "transport_connected generation=\(generation)", generation: generation) + recordProviderEvent(category: "websocket", message: "transport_connected generation=\(generation)", generation: generation, flightEvent: .bridgeConnected) if shouldApplySettings { let pendingAppliedIPv4 = clientIPv4 @@ -583,7 +644,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard let self else { return } if let error { logger.error("setTunnelNetworkSettings error: \(error.localizedDescription)") - self.recordProviderEvent(category: "settings", message: "apply_failed \(error.localizedDescription)", generation: generation) + self.recordProviderEvent(category: "settings", message: "apply_failed \(error.localizedDescription)", generation: generation, flightEvent: .bridgeStopRequested) self.updateRuntimeState(.failed, reason: "setTunnelNetworkSettings error") self.finishStart(with: error) self.replaceWebSocketClient(with: nil, stopCurrent: true) @@ -600,7 +661,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { self.stateLock.unlock() self.updateRuntimeState(.connected, reason: "websocket connected and settings applied") - self.recordProviderEvent(category: "settings", message: "apply_success generation=\(generation)", generation: generation) + self.recordProviderEvent(category: "settings", message: "apply_success generation=\(generation)", generation: generation, flightEvent: .tunnelConnected) self.clearReadBackpressure() self.startReadLoop() self.startTelemetryIfNeeded() @@ -616,7 +677,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { stateLock.unlock() updateRuntimeState(.connected, reason: "transport recovered") - recordProviderEvent(category: "websocket", message: "transport_recovered generation=\(generation)", generation: generation) + recordProviderEvent(category: "websocket", message: "transport_recovered generation=\(generation)", generation: generation, flightEvent: .bridgeConnected) updateDiagnosticsHeartbeat(lastEvent: "transport_recovered") clearReadBackpressure() startReadLoop() @@ -641,7 +702,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { if isStaleCallback { logger.info("Ignoring stale websocket disconnected callback generation=\(generation) reason=\(reason)") - recordProviderEvent(category: "websocket", message: "ignore_stale_disconnected generation=\(generation) reason=\(reason)", generation: generation) + recordProviderEvent(category: "websocket", message: "ignore_stale_disconnected generation=\(generation) reason=\(reason)", generation: generation, flightEvent: .transportDisconnected) return } @@ -657,7 +718,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { recordProviderEvent( category: "websocket", 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 + generation: generation, + flightEvent: .transportDisconnected ) updateDiagnosticsHeartbeat(lastEvent: "transport_disconnected") applyReadBackpressure(delay: sendFailureBackpressureMaxDelaySeconds, reason: "transport_disconnected") @@ -675,7 +737,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard currentState != .stopping else { logger.info("Ignoring transport disconnect because tunnel is already stopping") - recordProviderEvent(category: "reconnect", message: "skip_disconnect_already_stopping", runtimeState: currentState.rawValue, generation: generation) + recordProviderEvent(category: "reconnect", message: "skip_disconnect_already_stopping", runtimeState: currentState.rawValue, generation: generation, flightEvent: .stopTunnelEntered) return } @@ -683,7 +745,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { logger.info( "Suppressing reconnect after transport disconnect because stop was already requested by \(stopInitiator?.rawValue ?? "shutdown")" ) - recordProviderEvent(category: "reconnect", message: "suppress_after_stop initiator=\(stopInitiator?.rawValue ?? "shutdown")", runtimeState: currentState.rawValue, generation: generation) + recordProviderEvent(category: "reconnect", message: "suppress_after_stop initiator=\(stopInitiator?.rawValue ?? "shutdown")", runtimeState: currentState.rawValue, generation: generation, flightEvent: .stopTunnelEntered) updateRuntimeState(.stopping, reason: "transport disconnected after local stop request") return } @@ -702,7 +764,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { category: "reconnect", message: "initial_transport_no_path reason=\(reason)", runtimeState: currentState.rawValue, - generation: generation + generation: generation, + flightEvent: .reconnectScheduled ) switch scheduleReconnectIfPossible() { case .scheduled: @@ -716,7 +779,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { return } - recordProviderEvent(category: "reconnect", message: "initial_transport_failure reason=\(reason)", runtimeState: currentState.rawValue, generation: generation) + recordProviderEvent(category: "reconnect", message: "initial_transport_failure reason=\(reason)", runtimeState: currentState.rawValue, generation: generation, flightEvent: .reconnectScheduled) updateRuntimeState(.failed, reason: "initial transport failure") finishStart(with: makeError(reason)) return @@ -745,14 +808,14 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard let currentConfiguration = configuration else { stateLock.unlock() logger.info("Skipping reconnect schedule because runtime configuration is missing") - recordProviderEvent(category: "reconnect", message: "skip_schedule_missing_configuration") + recordProviderEvent(category: "reconnect", message: "skip_schedule_missing_configuration", flightEvent: .reconnectScheduled) return .unavailable } guard !shutdownRequested else { stateLock.unlock() logger.info("Skipping reconnect schedule because shutdown was requested") - recordProviderEvent(category: "reconnect", message: "skip_schedule_shutdown") + recordProviderEvent(category: "reconnect", message: "skip_schedule_shutdown", flightEvent: .reconnectScheduled) return .unavailable } @@ -766,7 +829,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard currentConfiguration.reconnectEnabled else { stateLock.unlock() logger.info("Skipping reconnect schedule because reconnect is disabled") - recordProviderEvent(category: "reconnect", message: "skip_schedule_disabled") + recordProviderEvent(category: "reconnect", message: "skip_schedule_disabled", flightEvent: .reconnectScheduled) return .unavailable } @@ -785,7 +848,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { message: "waiting_for_network attempt=\(nextAttempt)", generation: generation, reconnectAttempt: nextAttempt, - pathSatisfied: false + pathSatisfied: false, + flightEvent: .reconnectScheduled ) updateDiagnosticsHeartbeat(lastEvent: "waiting_for_network") return .waitingForNetwork @@ -815,7 +879,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { message: "schedule attempt=\(nextAttempt) delay=\(delaySeconds)s budget_exceeded=\(exceededConfiguredBudget)", generation: generation, reconnectAttempt: nextAttempt, - pathSatisfied: true + pathSatisfied: true, + flightEvent: .reconnectScheduled ) updateDiagnosticsHeartbeat(lastEvent: "reconnect_scheduled") @@ -846,18 +911,18 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { if shouldSkip { logger.info("Skipping reconnect attempt \(attempt) because it is stale or shutdown was requested") - recordProviderEvent(category: "reconnect", message: "skip_attempt_stale_or_shutdown attempt=\(attempt)", reconnectAttempt: attempt) + recordProviderEvent(category: "reconnect", message: "skip_attempt_stale_or_shutdown attempt=\(attempt)", reconnectAttempt: attempt, flightEvent: .reconnectStarted) return } guard currentState == .reasserting, let configuration else { - recordProviderEvent(category: "reconnect", message: "skip_attempt_state=\(currentState.rawValue) attempt=\(attempt)", runtimeState: currentState.rawValue, reconnectAttempt: attempt) + recordProviderEvent(category: "reconnect", message: "skip_attempt_state=\(currentState.rawValue) attempt=\(attempt)", runtimeState: currentState.rawValue, reconnectAttempt: attempt, flightEvent: .reconnectStarted) return } guard pathSatisfied else { logger.warning("Reconnect attempt \(attempt) delayed because network path is unsatisfied") - recordProviderEvent(category: "reconnect", message: "delay_attempt_path_unsatisfied attempt=\(attempt)", runtimeState: currentState.rawValue, reconnectAttempt: attempt, pathSatisfied: false) + recordProviderEvent(category: "reconnect", message: "delay_attempt_path_unsatisfied attempt=\(attempt)", runtimeState: currentState.rawValue, reconnectAttempt: attempt, pathSatisfied: false, flightEvent: .reconnectScheduled) updateRuntimeState(.waitingForNetwork, reason: "network path unsatisfied before reconnect") return } @@ -865,7 +930,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { logger.warning( "Starting reconnect attempt \(attempt)\(maxAttempts == 0 ? " (unlimited)" : "/\(maxAttempts)") \(activityDiagnosticsDescription())" ) - recordProviderEvent(category: "reconnect", message: "start_attempt attempt=\(attempt)", reconnectAttempt: attempt, pathSatisfied: true) + recordProviderEvent(category: "reconnect", message: "start_attempt attempt=\(attempt)", reconnectAttempt: attempt, pathSatisfied: true, flightEvent: .reconnectStarted) updateDiagnosticsHeartbeat(lastEvent: "reconnect_attempt_start") startWebSocket(using: configuration, context: "reconnect_attempt_\(attempt)") } @@ -887,7 +952,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { updateRuntimeState(.failed, reason: "runtime reconnect exhausted") logger.error("Tunnel runtime failure: \(reason)") - recordProviderEvent(category: "failure", message: "failRuntimeTunnel reason=\(reason)", runtimeState: "failed") + recordProviderEvent(category: "failure", message: "failRuntimeTunnel reason=\(reason)", runtimeState: "failed", flightEvent: .tunnelStopped) updateDiagnosticsHeartbeat(lastEvent: "failRuntimeTunnel") cancelPendingReconnect() cancelTunnelWithError(makeError(reason)) @@ -937,7 +1002,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { logger.info( "Applying tunnel settings ipv4_enabled=true ipv6_enabled=\(ipv6Enabled) dns_server_count=\(dnsServers.count) mtu=1400" ) - recordProviderEvent(category: "settings", message: "apply_start ipv6=\(ipv6Enabled)") + recordProviderEvent(category: "settings", message: "apply_start ipv6=\(ipv6Enabled)", flightEvent: .tunnelConnected) setTunnelNetworkSettings(settings, completionHandler: completionHandler) } @@ -1096,7 +1161,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { if !alreadyLogged { logger.error("INVARIANT_VIOLATION: \(invariant)") - recordProviderEvent(category: "invariant", message: "violation \(invariant)") + recordProviderEvent(category: "invariant", message: "violation \(invariant)", flightEvent: .invariantViolation) } } @@ -1274,7 +1339,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { readBackpressureUntil = until } stateLock.unlock() - recordProviderEvent(category: "packet_flow", message: "read_backpressure reason=\(reason) delay=\(String(format: "%.2f", clampedDelay))s") + recordProviderEvent(category: "packet_flow", message: "read_backpressure reason=\(reason) delay=\(String(format: "%.2f", clampedDelay))s", flightEvent: .queueHighWater) } private func scheduleReadLoopAfterBackpressure(delay: TimeInterval) { @@ -1436,7 +1501,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard didPathChange || shouldScheduleReconnect else { return } logger.info("Network path satisfied=\(isSatisfied) network=\(pathSummary)") - recordProviderEvent(category: "path", message: "path_satisfied=\(isSatisfied) network=\(pathSummary)", pathSatisfied: isSatisfied) + recordProviderEvent(category: "path", message: "path_satisfied=\(isSatisfied) network=\(pathSummary)", pathSatisfied: isSatisfied, flightEvent: .pathChanged) updateDiagnosticsHeartbeat(lastEvent: "path_satisfied=\(isSatisfied) network=\(pathSummary)") if didPathChange { applyReadBackpressure(delay: pathChangeBackpressureDelaySeconds, reason: "network_path_changed") @@ -1507,7 +1572,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard let configuration, !shutdownRequested, isNetworkPathSatisfied else { stateLock.unlock() logger.info("Skipping reconnect attempt \(attempt) after path change because state is no longer reconnectable") - recordProviderEvent(category: "reconnect", message: "skip_path_recovery_not_reconnectable attempt=\(attempt)", reconnectAttempt: attempt) + recordProviderEvent(category: "reconnect", message: "skip_path_recovery_not_reconnectable attempt=\(attempt)", reconnectAttempt: attempt, flightEvent: .reconnectScheduled) return } @@ -1541,7 +1606,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { message: "schedule_after_path_recovery attempt=\(attempt) delay=\(delaySeconds)s budget_exceeded=\(exceededConfiguredBudget)", generation: generation, reconnectAttempt: attempt, - pathSatisfied: true + pathSatisfied: true, + flightEvent: .reconnectScheduled ) updateDiagnosticsHeartbeat(lastEvent: "reconnect_scheduled_after_path") @@ -1596,7 +1662,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { residentBytes: snapshot.memoryResidentBytes, physFootprintBytes: snapshot.memoryPhysFootprintBytes ) - updateDiagnosticsHeartbeat(snapshot: snapshot, lastEvent: "telemetry") + writeLifecycleSnapshot() evaluateMemoryPressure(memory: memory) #endif } @@ -1617,11 +1683,11 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { reasserting = newReasserting if previousState != state { logger.info("Tunnel state \(previousState.rawValue) -> \(state.rawValue) reason=\(reason)") - recordProviderEvent(category: "state", message: "\(previousState.rawValue)->\(state.rawValue) reason=\(reason)", runtimeState: state.rawValue) + recordProviderEvent(category: "state", message: "\(previousState.rawValue)->\(state.rawValue) reason=\(reason)", runtimeState: state.rawValue, flightEvent: .tunnelConnected) } if previousReasserting != newReasserting { logger.info("Tunnel reasserting=\(newReasserting)") - recordProviderEvent(category: "state", message: "reasserting=\(newReasserting)", runtimeState: state.rawValue) + recordProviderEvent(category: "state", message: "reasserting=\(newReasserting)", runtimeState: state.rawValue, flightEvent: .tunnelConnected) } updateDiagnosticsHeartbeat(lastEvent: "state=\(state.rawValue)") } @@ -1636,7 +1702,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { startTimeoutWorkItem = nil stateLock.unlock() - recordProviderEvent(category: "startup", message: "finishStart error=\(error?.localizedDescription ?? "none")") + recordProviderEvent(category: "startup", message: "finishStart error=\(error?.localizedDescription ?? "none")", flightEvent: .tunnelStopped) updateDiagnosticsHeartbeat(lastEvent: "finishStart") completion?(error) } @@ -1763,77 +1829,118 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { // The #if gate eliminates the entire call (including category string // literals) at compile time rather than relying on a runtime guard // inside TunnelDiagnosticsStore. + // PR3: binary-only event recording. No JSONL, no string evaluation. + // flightEvent is mandatory — explicit numeric codes are the contract. private func recordProviderEvent( category: String, message: @autoclosure () -> String, runtimeState: String? = nil, generation: Int? = nil, reconnectAttempt: Int? = nil, - pathSatisfied: Bool? = nil + pathSatisfied: Bool? = nil, + flightEvent: TunnelFlightEventCode ) { - #if FPTN_MEASUREMENT_BUILD - return - #else - TunnelDiagnosticsStore.shared.recordProviderEvent( - category: category, - message: message(), - runtimeState: runtimeState, - generation: generation, - reconnectAttempt: reconnectAttempt, - pathSatisfied: pathSatisfied - ) - #endif + if let seq = flightRecorder?.recordForSession( + flightEvent, + sessionToken: tunnelSessionToken, + generation: UInt32(generation ?? 0) + ), seq > 0 { + diagnosticsLock.lock() + latestEventSequence = seq + diagnosticsLock.unlock() + } } - // PR-1 (Measurement Safety): @autoclosure prevents interpolated - // lastEvent strings (e.g. "state=\(state.rawValue)") from being - // constructed in Measurement builds. + // PR3: binary-only lifecycle snapshot. No JSONL heartbeat. + // The @autoclosure lastEvent is never evaluated. private func updateDiagnosticsHeartbeat(lastEvent: @autoclosure () -> String) { - #if FPTN_MEASUREMENT_BUILD - return - #else - updateDiagnosticsHeartbeat(snapshot: currentSnapshot(), lastEvent: lastEvent()) - #endif + writeLifecycleSnapshot() } - private func updateDiagnosticsHeartbeat(snapshot: TunnelRuntimeSnapshot, lastEvent: @autoclosure () -> String) { - #if FPTN_MEASUREMENT_BUILD - return - #endif - let event = lastEvent() + private func writeLifecycleSnapshot(synchronize: Bool = false) { let generation: Int let pathSatisfied: Bool + let currentState: TunnelRuntimeState + let reconnAttempt: Int + let stopInitiator: LocalStopInitiator? + let isShutdown: Bool + let readLoopActive: Bool + let readPending: Bool + let stopReasonRaw: Int? + let client: WebsocketClientBridge? + let savedNativeStatus: WebsocketClientStatus? + stateLock.lock() generation = websocketGeneration pathSatisfied = isNetworkPathSatisfied + currentState = runtimeState + reconnAttempt = reconnectAttempt + stopInitiator = localStopInitiator + isShutdown = shutdownRequested + readLoopActive = isReadLoopActive + readPending = isPacketReadPending + stopReasonRaw = lastStopReasonRawValue + client = wsClient + savedNativeStatus = lastNativeStatus stateLock.unlock() - TunnelDiagnosticsStore.shared.writeHeartbeat( - TunnelProviderHeartbeat( - timestamp: TunnelDiagnosticsStore.now(), - runtimeState: snapshot.runtimeState.rawValue, - isReasserting: snapshot.isReasserting, - generation: generation, - reconnectAttempt: snapshot.reconnectAttempt, - maxReconnectAttempts: snapshot.maxReconnectAttempts, - pathSatisfied: pathSatisfied, - websocketStarted: snapshot.websocketStarted, - websocketRunning: snapshot.websocketRunning, - lastTransportError: snapshot.lastTransportError ?? snapshot.websocketLastError, - lastStopReason: snapshot.lastStopReason, - lastEvent: event, - lastInboundActivityAt: snapshot.lastInboundActivityAt, - lastOutboundActivityAt: snapshot.lastOutboundActivityAt, - packetFlowReadPackets: snapshot.packetFlowReadPackets, - packetFlowWritePackets: snapshot.packetFlowWritePackets, - transportReceivedPackets: snapshot.transportReceivedPackets, - websocketSendFailures: snapshot.websocketSendFailures, - memoryResidentBytes: snapshot.memoryResidentBytes, - memoryPhysFootprintBytes: snapshot.memoryPhysFootprintBytes, - localStopInitiator: snapshot.localStopInitiator, - nativeDisconnectCode: snapshot.nativeDisconnectCode, - nativeStopOrigin: snapshot.nativeStopOrigin - ) + // Query native status outside stateLock. + let liveStatus = client?.status + let status = liveStatus ?? savedNativeStatus + let identity = ProviderProcessIdentity.shared + + let footprint = status?.memoryPhysFootprintBytes ?? physFootprintBytes() ?? 0 + let resident = status?.memoryResidentBytes ?? residentMemoryBytes() ?? 0 + + // Protect peak + sequence under diagnosticsLock. + diagnosticsLock.lock() + if footprint > physicalFootprintPeakBytes { + physicalFootprintPeakBytes = footprint + } + let peakBytes = physicalFootprintPeakBytes + let eventSeq = latestEventSequence + diagnosticsLock.unlock() + + let hasBridge = client != nil + + var flags: UInt32 = 0 + if isShutdown { flags |= TunnelSnapshotFlag.shutdownRequested.rawValue } + if currentState == .reasserting || currentState == .waitingForNetwork { + flags |= TunnelSnapshotFlag.reasserting.rawValue + } + if pathSatisfied { flags |= TunnelSnapshotFlag.pathSatisfied.rawValue } + if readLoopActive { flags |= TunnelSnapshotFlag.readLoopActive.rawValue } + if readPending { flags |= TunnelSnapshotFlag.packetReadPending.rawValue } + if status?.stopCleanupCompleted == true { flags |= TunnelSnapshotFlag.nativeStopCleanupCompleted.rawValue } + + lifecycleStore?.write( + pid: UInt32(identity.pid), + processToken: identity.processToken, + processSequence: identity.processSequence, + processStartedMachTime: identity.startedMachTime, + tunnelSessionToken: tunnelSessionToken, + tunnelStartedMachTime: tunnelStartedMachTime, + providerStopReason: UInt32(stopReasonRaw ?? 0), + localStopInitiator: stopInitiator?.binaryCode ?? 0, + nativeDisconnectCode: status?.disconnectCode.rawValue ?? 0, + nativeStopOrigin: status?.stopOrigin.rawValue ?? 0, + flags: flags, + lifecycleState: currentState.binaryCode, + websocketGeneration: UInt32(generation), + reconnectAttempt: UInt32(reconnAttempt), + physicalFootprintBytes: footprint, + physicalFootprintPeakBytes: peakBytes, + residentBytes: resident, + activeBridges: hasBridge ? 1 : 0, + activeNativeClients: UInt32(status?.liveClients ?? 0), + nativeActiveOperations: status?.activeOperations ?? 0, + activeReaderCoroutines: UInt32(status?.activeReaderCoroutines ?? 0), + activeSenderCoroutines: UInt32(status?.activeSenderCoroutines ?? 0), + activeReadLoops: readLoopActive ? 1 : 0, + outboundQueuedBytes: status?.queuedBytes ?? 0, + outboundQueuedBytesPeak: status?.queuedBytesPeak ?? 0, + latestEventSequence: eventSeq, + synchronize: synchronize ) } diff --git a/SharedTunnelRuntime/TunnelFlightRecorder.swift b/SharedTunnelRuntime/TunnelFlightRecorder.swift new file mode 100644 index 0000000..27fa356 --- /dev/null +++ b/SharedTunnelRuntime/TunnelFlightRecorder.swift @@ -0,0 +1,110 @@ +import Foundation + +// PR3: process-lifetime identity. Created once per provider process. +final class ProviderProcessIdentity: @unchecked Sendable { + static let shared = ProviderProcessIdentity() + + let pid: UInt64 + let processToken: UInt64 + let startedMachTime: UInt64 + let processSequence: UInt64 + + private init() { + pid = UInt64(getpid()) + processToken = Self.secureRandomUInt64() + startedMachTime = mach_continuous_time() + processSequence = UInt64(Date().timeIntervalSince1970 * 1_000_000) + } + + private static func secureRandomUInt64() -> UInt64 { + var value: UInt64 = 0 + _ = SecRandomCopyBytes(kSecRandomDefault, 8, &value) + return value + } +} + +// PR3: flight event codes matching the C++ EventCode enum. +enum TunnelFlightEventCode: UInt16, Sendable { + case processStarted = 1 + case startTunnel = 2 + case tunnelConnected = 3 + case stopTunnelEntered = 4 + case tunnelStopped = 5 + case bridgeCreated = 6 + case bridgeStartRequested = 7 + case bridgeConnected = 8 + case bridgeStopRequested = 9 + case bridgeTeardownCompleted = 10 + case readerStarted = 11 + case readerStopped = 12 + case senderStarted = 13 + case senderStopped = 14 + case reconnectScheduled = 15 + case reconnectStarted = 16 + case transportDisconnected = 17 + case pathChanged = 18 + case memorySample = 19 + case memoryWarning = 20 + case memoryCritical = 21 + case queueHighWater = 22 + case invariantViolation = 23 + case snapshotWriteFailed = 24 +} + +// PR3: RAII Swift wrapper over the C flight recorder. +// Allocation-free on the record path. Thread-safe (C mutex). +// C ABI uses void* handles; Swift stores UnsafeMutableRawPointer. +final class TunnelFlightRecorder: @unchecked Sendable { + private let handle: UnsafeMutableRawPointer + private let identity = ProviderProcessIdentity.shared + + init?(path: String) { + guard let h = path.withCString({ fptn_flight_recorder_create($0) }) else { + return nil + } + handle = h + } + + deinit { + fptn_flight_recorder_destroy(handle) + } + + @inline(__always) + func record( + _ code: TunnelFlightEventCode, + generation: UInt32 = 0, + flags: UInt16 = 0, + value0: UInt64 = 0, + value1: UInt64 = 0, + value2: UInt64 = 0, + synchronize: Bool = false + ) -> UInt64 { + fptn_flight_recorder_record( + handle, code.rawValue, flags, generation, + identity.processToken, 0, mach_continuous_time(), + value0, value1, value2, synchronize ? 1 : 0 + ) + } + + @inline(__always) + func recordForSession( + _ code: TunnelFlightEventCode, + sessionToken: UInt64, + generation: UInt32 = 0, + flags: UInt16 = 0, + value0: UInt64 = 0, + value1: UInt64 = 0, + value2: UInt64 = 0, + synchronize: Bool = false + ) -> UInt64 { + fptn_flight_recorder_record( + handle, code.rawValue, flags, generation, + identity.processToken, sessionToken, mach_continuous_time(), + value0, value1, value2, synchronize ? 1 : 0 + ) + } + + func flush() -> Bool { + fptn_flight_recorder_flush(handle) != 0 + } +} diff --git a/SharedTunnelRuntime/TunnelLifecycleSnapshotStore.swift b/SharedTunnelRuntime/TunnelLifecycleSnapshotStore.swift new file mode 100644 index 0000000..d20fcdf --- /dev/null +++ b/SharedTunnelRuntime/TunnelLifecycleSnapshotStore.swift @@ -0,0 +1,74 @@ +import Foundation + +// PR3: snapshot flags matching the C++ SnapshotFlags enum. +enum TunnelSnapshotFlag: UInt32 { + case shutdownRequested = 1 + case reasserting = 2 + case pathSatisfied = 4 + case readLoopActive = 8 + case packetReadPending = 16 + case nativeStopCleanupCompleted = 32 + case recorderError = 64 +} + +// PR3: RAII Swift wrapper over the C lifecycle snapshot store. +final class TunnelLifecycleSnapshotStore: @unchecked Sendable { + private let handle: UnsafeMutableRawPointer + + init?(path: String) { + guard let h = path.withCString({ fptn_lifecycle_store_create($0) }) else { + return nil + } + handle = h + } + + deinit { + fptn_lifecycle_store_destroy(handle) + } + + func write( + pid: UInt32, + processToken: UInt64, + processSequence: UInt64, + processStartedMachTime: UInt64, + tunnelSessionToken: UInt64, + tunnelStartedMachTime: UInt64, + providerStopReason: UInt32 = 0, + localStopInitiator: UInt16 = 0, + nativeDisconnectCode: UInt16 = 0, + nativeStopOrigin: UInt16 = 0, + flags: UInt32 = 0, + lifecycleState: UInt32 = 0, + websocketGeneration: UInt32 = 0, + reconnectAttempt: UInt32 = 0, + physicalFootprintBytes: UInt64 = 0, + physicalFootprintPeakBytes: UInt64 = 0, + residentBytes: UInt64 = 0, + activeBridges: UInt32 = 0, + activeNativeClients: UInt32 = 0, + nativeActiveOperations: UInt32 = 0, + activeReaderCoroutines: UInt32 = 0, + activeSenderCoroutines: UInt32 = 0, + activeReadLoops: UInt32 = 0, + outboundQueuedBytes: UInt64 = 0, + outboundQueuedBytesPeak: UInt64 = 0, + latestEventSequence: UInt64 = 0, + synchronize: Bool = false + ) -> Bool { + fptn_lifecycle_store_write( + handle, + pid, processToken, processSequence, processStartedMachTime, + tunnelSessionToken, tunnelStartedMachTime, + providerStopReason, localStopInitiator, + nativeDisconnectCode, nativeStopOrigin, + mach_continuous_time(), + UInt64(Date().timeIntervalSince1970 * 1_000_000_000), + flags, lifecycleState, websocketGeneration, reconnectAttempt, + physicalFootprintBytes, physicalFootprintPeakBytes, residentBytes, + activeBridges, activeNativeClients, nativeActiveOperations, + activeReaderCoroutines, activeSenderCoroutines, activeReadLoops, + outboundQueuedBytes, outboundQueuedBytesPeak, latestEventSequence, + synchronize ? 1 : 0 + ) != 0 + } +} diff --git a/TunnelDiagnosticsCore/include/FptnTunnelDiagnostics.h b/TunnelDiagnosticsCore/include/FptnTunnelDiagnostics.h new file mode 100644 index 0000000..441cda0 --- /dev/null +++ b/TunnelDiagnosticsCore/include/FptnTunnelDiagnostics.h @@ -0,0 +1,158 @@ +// FptnTunnelDiagnostics.h — C-compatible public ABI. +// This is the ONLY header visible to Swift. +// Implementation is C++23 with RAII; Swift sees opaque handles. + +#ifndef FPTN_TUNNEL_DIAGNOSTICS_H +#define FPTN_TUNNEL_DIAGNOSTICS_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ── Flight Recorder ────────────────────────────────────────────────── + + + +// Opens or creates a flight recorder ring file. +// Returns NULL on failure. +void * +fptn_flight_recorder_create(const char *path); + +// Destroys the recorder and closes the file. +void +fptn_flight_recorder_destroy(void *handle); + +// Flushes the ring file to disk. +int +fptn_flight_recorder_flush(void *handle); + +// Records an event. Returns the assigned sequence (0 on failure). +uint64_t +fptn_flight_recorder_record( + void *handle, + uint16_t event_code, + uint16_t flags, + uint32_t generation, + uint64_t process_token, + uint64_t session_token, + uint64_t mach_continuous_time, + uint64_t value_0, + uint64_t value_1, + uint64_t value_2, + int synchronize); + +// ── Lifecycle Snapshot Store ───────────────────────────────────────── + + + +// Opens or creates a double-buffered snapshot file. +void * +fptn_lifecycle_store_create(const char *path); + +void +fptn_lifecycle_store_destroy(void *handle); + +// Writes a snapshot. If synchronize is true, fsyncs after the write. +int +fptn_lifecycle_store_write( + void *handle, + uint32_t pid, + uint64_t process_token, + uint64_t process_sequence, + uint64_t process_started_mach_time, + uint64_t tunnel_session_token, + uint64_t tunnel_started_mach_time, + uint32_t provider_stop_reason, + uint16_t local_stop_initiator, + uint16_t native_disconnect_code, + uint16_t native_stop_origin, + uint64_t snapshot_mach_continuous_time, + uint64_t snapshot_wall_time_ns, + uint32_t flags, + uint32_t lifecycle_state, + uint32_t websocket_generation, + uint32_t reconnect_attempt, + uint64_t physical_footprint_bytes, + uint64_t physical_footprint_peak_bytes, + uint64_t resident_bytes, + uint32_t active_bridges, + uint32_t active_native_clients, + uint32_t native_active_operations, + uint32_t active_reader_coroutines, + uint32_t active_sender_coroutines, + uint32_t active_read_loops, + uint64_t outbound_queued_bytes, + uint64_t outbound_queued_bytes_peak, + uint64_t latest_event_sequence, + int synchronize); + +// ── Decoded snapshot (returned by reader) ──────────────────────────── + +typedef struct { + uint64_t write_sequence; + uint32_t pid; + uint64_t process_token; + uint64_t process_sequence; + uint64_t process_started_mach_time; + uint64_t tunnel_session_token; + uint64_t tunnel_started_mach_time; + uint32_t provider_stop_reason; + uint16_t local_stop_initiator; + uint16_t native_disconnect_code; + uint16_t native_stop_origin; + uint64_t snapshot_mach_continuous_time; + uint64_t snapshot_wall_time_ns; + uint32_t flags; + uint32_t lifecycle_state; + uint32_t websocket_generation; + uint32_t reconnect_attempt; + uint64_t physical_footprint_bytes; + uint64_t physical_footprint_peak_bytes; + uint64_t resident_bytes; + uint32_t active_bridges; + uint32_t active_native_clients; + uint32_t native_active_operations; + uint32_t active_reader_coroutines; + uint32_t active_sender_coroutines; + uint32_t active_read_loops; + uint64_t outbound_queued_bytes; + uint64_t outbound_queued_bytes_peak; + uint64_t latest_event_sequence; +} FptnDecodedLifecycleSnapshot; + +// Reads the latest valid snapshot from a double-buffered file. +int +fptn_lifecycle_store_read_latest( + const char *path, + FptnDecodedLifecycleSnapshot *output); + +// ── Decoded types and reader functions ─────────────────────────────── + +typedef struct { + uint64_t sequence; + uint64_t mach_continuous_time; + uint64_t process_token; + uint64_t tunnel_session_token; + uint16_t event_code; + uint16_t flags; + uint32_t websocket_generation; + uint64_t value_0; + uint64_t value_1; + uint64_t value_2; +} FptnDecodedFlightEvent; + +size_t +fptn_flight_recorder_read_valid( + const char *path, + FptnDecodedFlightEvent *output, + size_t output_capacity); + +#ifdef __cplusplus +} +#endif + +#endif // FPTN_TUNNEL_DIAGNOSTICS_H diff --git a/TunnelDiagnosticsCore/src/CApi.cpp b/TunnelDiagnosticsCore/src/CApi.cpp new file mode 100644 index 0000000..ff2b2e6 --- /dev/null +++ b/TunnelDiagnosticsCore/src/CApi.cpp @@ -0,0 +1,228 @@ +#include "../include/FptnTunnelDiagnostics.h" + +#include + +#include "DiagnosticsReader.hpp" +#include "FlightRecorder.hpp" +#include "LifecycleStore.hpp" + +// ── Flight Recorder ────────────────────────────────────────────────── + +struct FptnFlightRecorderHandle { + explicit FptnFlightRecorderHandle(fptn::diag::FlightRecorder* r) + : recorder(r) {} + ~FptnFlightRecorderHandle() { delete recorder; } + + fptn::diag::FlightRecorder* recorder; +}; + +extern "C" void* +fptn_flight_recorder_create(const char* path) { + auto* recorder = fptn::diag::FlightRecorder::Open(path); + if (!recorder) return nullptr; + auto* handle = new (std::nothrow) FptnFlightRecorderHandle(recorder); + return static_cast(handle); +} + +extern "C" void +fptn_flight_recorder_destroy(void* handle) { + delete static_cast(handle); +} + +extern "C" uint64_t +fptn_flight_recorder_record( + void* handle, + uint16_t event_code, + uint16_t flags, + uint32_t generation, + uint64_t process_token, + uint64_t session_token, + uint64_t mach_continuous_time, + uint64_t value_0, + uint64_t value_1, + uint64_t value_2, + int synchronize) { + auto* rec = static_cast(handle); if (!rec || !rec->recorder) return 0; + + fptn::diag::Event event; + event.event_code = event_code; + event.flags = flags; + event.websocket_generation = generation; + event.process_token = process_token; + event.tunnel_session_token = session_token; + event.mach_continuous_time = mach_continuous_time; + event.value_0 = value_0; + event.value_1 = value_1; + event.value_2 = value_2; + + return rec->recorder->Record(event, synchronize); +} + +extern "C" int +fptn_flight_recorder_flush(void* handle) { + auto* rec = static_cast(handle); if (!rec || !rec->recorder) return false; + return rec->recorder->Flush() ? 1 : 0; +} + +extern "C" size_t +fptn_flight_recorder_read_valid( + const char* path, + FptnDecodedFlightEvent* output, + size_t output_capacity) { + if (!path || !output || output_capacity == 0) return 0; + + // Use a stack buffer for intermediate events. + static constexpr size_t kMaxStackEvents = 256; + fptn::diag::Event events[kMaxStackEvents]; + const size_t cap = + output_capacity < kMaxStackEvents ? output_capacity : kMaxStackEvents; + + const size_t count = fptn::diag::ReadValidEvents(path, events, cap); + + for (size_t i = 0; i < count; ++i) { + output[i].sequence = events[i].sequence; + output[i].mach_continuous_time = events[i].mach_continuous_time; + output[i].process_token = events[i].process_token; + output[i].tunnel_session_token = events[i].tunnel_session_token; + output[i].event_code = events[i].event_code; + output[i].flags = events[i].flags; + output[i].websocket_generation = events[i].websocket_generation; + output[i].value_0 = events[i].value_0; + output[i].value_1 = events[i].value_1; + output[i].value_2 = events[i].value_2; + } + + return count; +} + +// ── Lifecycle Snapshot Store ───────────────────────────────────────── + +struct FptnLifecycleStoreHandle { + explicit FptnLifecycleStoreHandle(fptn::diag::LifecycleStore* s) + : store(s) {} + ~FptnLifecycleStoreHandle() { delete store; } + + fptn::diag::LifecycleStore* store; +}; + +extern "C" void* +fptn_lifecycle_store_create(const char* path) { + auto* store = fptn::diag::LifecycleStore::Open(path); + if (!store) return nullptr; + auto* handle = new (std::nothrow) FptnLifecycleStoreHandle(store); + return static_cast(handle); +} + +extern "C" void +fptn_lifecycle_store_destroy(void* handle) { + delete handle; +} + +extern "C" int +fptn_lifecycle_store_write( + void* handle, + uint32_t pid, + uint64_t process_token, + uint64_t process_sequence, + uint64_t process_started_mach_time, + uint64_t tunnel_session_token, + uint64_t tunnel_started_mach_time, + uint32_t provider_stop_reason, + uint16_t local_stop_initiator, + uint16_t native_disconnect_code, + uint16_t native_stop_origin, + uint64_t snapshot_mach_continuous_time, + uint64_t snapshot_wall_time_ns, + uint32_t flags, + uint32_t lifecycle_state, + uint32_t websocket_generation, + uint32_t reconnect_attempt, + uint64_t physical_footprint_bytes, + uint64_t physical_footprint_peak_bytes, + uint64_t resident_bytes, + uint32_t active_bridges, + uint32_t active_native_clients, + uint32_t native_active_operations, + uint32_t active_reader_coroutines, + uint32_t active_sender_coroutines, + uint32_t active_read_loops, + uint64_t outbound_queued_bytes, + uint64_t outbound_queued_bytes_peak, + uint64_t latest_event_sequence, + int synchronize) { + auto* st = static_cast(handle); if (!st || !st->store) return false; + + fptn::diag::Snapshot snap; + snap.pid = pid; + snap.process_token = process_token; + snap.process_sequence = process_sequence; + snap.process_started_mach_time = process_started_mach_time; + snap.tunnel_session_token = tunnel_session_token; + snap.tunnel_started_mach_time = tunnel_started_mach_time; + snap.provider_stop_reason = provider_stop_reason; + snap.local_stop_initiator = local_stop_initiator; + snap.native_disconnect_code = native_disconnect_code; + snap.native_stop_origin = native_stop_origin; + snap.snapshot_mach_continuous_time = snapshot_mach_continuous_time; + snap.snapshot_wall_time_ns = snapshot_wall_time_ns; + snap.flags = flags; + snap.lifecycle_state = lifecycle_state; + snap.websocket_generation = websocket_generation; + snap.reconnect_attempt = reconnect_attempt; + snap.physical_footprint_bytes = physical_footprint_bytes; + snap.physical_footprint_peak_bytes = physical_footprint_peak_bytes; + snap.resident_bytes = resident_bytes; + snap.active_bridges = active_bridges; + snap.active_native_clients = active_native_clients; + snap.native_active_operations = native_active_operations; + snap.active_reader_coroutines = active_reader_coroutines; + snap.active_sender_coroutines = active_sender_coroutines; + snap.active_read_loops = active_read_loops; + snap.outbound_queued_bytes = outbound_queued_bytes; + snap.outbound_queued_bytes_peak = outbound_queued_bytes_peak; + snap.latest_event_sequence = latest_event_sequence; + + return st->store->Write(snap, synchronize != 0) ? 1 : 0; +} + +extern "C" int +fptn_lifecycle_store_read_latest( + const char* path, + FptnDecodedLifecycleSnapshot* output) { + if (!path || !output) return false; + + fptn::diag::Snapshot snap; + if (!fptn::diag::ReadLatestSnapshot(path, snap)) return 0; + + output->write_sequence = snap.write_sequence; + output->pid = snap.pid; + output->process_token = snap.process_token; + output->process_sequence = snap.process_sequence; + output->process_started_mach_time = snap.process_started_mach_time; + output->tunnel_session_token = snap.tunnel_session_token; + output->tunnel_started_mach_time = snap.tunnel_started_mach_time; + output->provider_stop_reason = snap.provider_stop_reason; + output->local_stop_initiator = snap.local_stop_initiator; + output->native_disconnect_code = snap.native_disconnect_code; + output->native_stop_origin = snap.native_stop_origin; + output->snapshot_mach_continuous_time = snap.snapshot_mach_continuous_time; + output->snapshot_wall_time_ns = snap.snapshot_wall_time_ns; + output->flags = snap.flags; + output->lifecycle_state = snap.lifecycle_state; + output->websocket_generation = snap.websocket_generation; + output->reconnect_attempt = snap.reconnect_attempt; + output->physical_footprint_bytes = snap.physical_footprint_bytes; + output->physical_footprint_peak_bytes = snap.physical_footprint_peak_bytes; + output->resident_bytes = snap.resident_bytes; + output->active_bridges = snap.active_bridges; + output->active_native_clients = snap.active_native_clients; + output->native_active_operations = snap.native_active_operations; + output->active_reader_coroutines = snap.active_reader_coroutines; + output->active_sender_coroutines = snap.active_sender_coroutines; + output->active_read_loops = snap.active_read_loops; + output->outbound_queued_bytes = snap.outbound_queued_bytes; + output->outbound_queued_bytes_peak = snap.outbound_queued_bytes_peak; + output->latest_event_sequence = snap.latest_event_sequence; + + return 1; +} diff --git a/TunnelDiagnosticsCore/src/DiagnosticsReader.cpp b/TunnelDiagnosticsCore/src/DiagnosticsReader.cpp new file mode 100644 index 0000000..633c93b --- /dev/null +++ b/TunnelDiagnosticsCore/src/DiagnosticsReader.cpp @@ -0,0 +1,86 @@ +#include "DiagnosticsReader.hpp" +#include "PosixFile.hpp" + +#include + +namespace fptn::diag { + +std::size_t ReadValidEvents(const char* path, Event* output, + std::size_t capacity) noexcept { + const int fd = ::open(path, O_RDONLY); + if (fd < 0) return 0; + PosixFile file(fd); + + // Validate header. + std::array header_bytes{}; + if (!PreadAll(fd, header_bytes.data(), header_bytes.size(), 0)) { + return 0; + } + + const std::uint32_t magic = + static_cast(static_cast(header_bytes[0])) | + (static_cast(static_cast(header_bytes[1])) << 8) | + (static_cast(static_cast(header_bytes[2])) << 16) | + (static_cast(static_cast(header_bytes[3])) << 24); + if (magic != kFlightRingMagic) { + return 0; + } + + // Read all slots, validate, collect. + std::size_t count = 0; + for (std::size_t slot = 0; + slot < kFlightRingCapacity && count < capacity; ++slot) { + const std::size_t offset = + kFlightRingHeaderSize + slot * kFlightRecordSize; + std::array record_bytes{}; + if (!PreadAll(fd, record_bytes.data(), record_bytes.size(), + static_cast(offset))) { + continue; + } + + Event event; + if (DecodeEvent(record_bytes, event) && event.sequence > 0) { + output[count++] = event; + } + } + + // Sort by sequence. + std::sort(output, output + count, + [](const Event& a, const Event& b) { + return a.sequence < b.sequence; + }); + + return count; +} + +bool ReadLatestSnapshot(const char* path, Snapshot& output) noexcept { + const int fd = ::open(path, O_RDONLY); + if (fd < 0) return false; + PosixFile file(fd); + + bool found = false; + std::uint64_t best_seq = 0; + + for (int slot = 0; slot < static_cast(kSnapshotSlotCount); ++slot) { + const std::size_t offset = + static_cast(slot) * kSnapshotMaxSize; + std::array bytes{}; + if (!PreadAll(fd, bytes.data(), bytes.size(), + static_cast(offset))) { + continue; + } + + Snapshot snap; + if (DecodeSnapshot(bytes, kSnapshotMaxSize, snap)) { + if (!found || snap.write_sequence > best_seq) { + best_seq = snap.write_sequence; + output = snap; + found = true; + } + } + } + + return found; +} + +} // namespace fptn::diag diff --git a/TunnelDiagnosticsCore/src/DiagnosticsReader.hpp b/TunnelDiagnosticsCore/src/DiagnosticsReader.hpp new file mode 100644 index 0000000..60898d0 --- /dev/null +++ b/TunnelDiagnosticsCore/src/DiagnosticsReader.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include "DiskFormat.hpp" + +namespace fptn::diag { + +// PR3: read and validate flight records from a ring file. +// Returns the number of valid events written to output. +// Events are ordered by sequence (ascending). +std::size_t ReadValidEvents(const char* path, Event* output, + std::size_t capacity) noexcept; + +// PR3: read the latest valid snapshot from a double-buffered file. +// Picks the valid slot with the highest write_sequence. +bool ReadLatestSnapshot(const char* path, Snapshot& output) noexcept; + +} // namespace fptn::diag diff --git a/TunnelDiagnosticsCore/src/DiskFormat.cpp b/TunnelDiagnosticsCore/src/DiskFormat.cpp new file mode 100644 index 0000000..e4299cf --- /dev/null +++ b/TunnelDiagnosticsCore/src/DiskFormat.cpp @@ -0,0 +1,271 @@ +#include "DiskFormat.hpp" + +#include + +namespace fptn::diag { + +namespace { + +// Little-endian write helpers. +void WriteU16(std::byte* dst, std::uint16_t v) noexcept { + dst[0] = static_cast(v & 0xFF); + dst[1] = static_cast((v >> 8) & 0xFF); +} + +void WriteU32(std::byte* dst, std::uint32_t v) noexcept { + dst[0] = static_cast(v & 0xFF); + dst[1] = static_cast((v >> 8) & 0xFF); + dst[2] = static_cast((v >> 16) & 0xFF); + dst[3] = static_cast((v >> 24) & 0xFF); +} + +void WriteU64(std::byte* dst, std::uint64_t v) noexcept { + for (int i = 0; i < 8; ++i) { + dst[i] = static_cast((v >> (i * 8)) & 0xFF); + } +} + +std::uint16_t ReadU16(const std::byte* src) noexcept { + return static_cast( + static_cast(src[0]) | + (static_cast(static_cast(src[1])) << 8)); +} + +std::uint32_t ReadU32(const std::byte* src) noexcept { + return static_cast(static_cast(src[0])) | + (static_cast(static_cast(src[1])) << 8) | + (static_cast(static_cast(src[2])) << 16) | + (static_cast(static_cast(src[3])) << 24); +} + +std::uint64_t ReadU64(const std::byte* src) noexcept { + std::uint64_t v = 0; + for (int i = 7; i >= 0; --i) { + v = (v << 8) | static_cast(static_cast(src[i])); + } + return v; +} + +// Record layout (72 bytes, little-endian): +// [0..7] sequence (commit marker, written last) +// [8..15] mach_continuous_time +// [16..23] process_token +// [24..31] tunnel_session_token +// [32..33] event_code +// [34..35] flags +// [36..39] websocket_generation +// [40..47] value_0 +// [48..55] value_1 +// [56..63] value_2 +// [64..67] checksum (CRC32 over bytes 8..63 with checksum=0) +// [68..71] reserved + +inline constexpr std::size_t kChecksumOffset = 64; +inline constexpr std::size_t kCrcStart = 8; +inline constexpr std::size_t kCrcEnd = 64; + +} // namespace + +std::uint32_t Crc32(const std::byte* data, std::size_t length) noexcept { + std::uint32_t crc = 0xFFFFFFFF; + for (std::size_t i = 0; i < length; ++i) { + crc ^= static_cast(static_cast(data[i])); + for (int bit = 0; bit < 8; ++bit) { + crc = (crc >> 1) ^ (0xEDB88320 & (~(crc & 1) + 1)); + } + } + return crc ^ 0xFFFFFFFF; +} + +RecordBytes EncodeEvent(const Event& event) noexcept { + RecordBytes bytes{}; // zero-init (reserved fields = 0) + + // Fill fields 8..63 first (sequence written last as commit). + WriteU64(&bytes[8], event.mach_continuous_time); + WriteU64(&bytes[16], event.process_token); + WriteU64(&bytes[24], event.tunnel_session_token); + WriteU16(&bytes[32], event.event_code); + WriteU16(&bytes[34], event.flags); + WriteU32(&bytes[36], event.websocket_generation); + WriteU64(&bytes[40], event.value_0); + WriteU64(&bytes[48], event.value_1); + WriteU64(&bytes[56], event.value_2); + + // CRC32 over bytes 8..63 (checksum field at 64..67 is zero). + const std::uint32_t crc = Crc32(&bytes[kCrcStart], kCrcEnd - kCrcStart); + WriteU32(&bytes[kChecksumOffset], crc); + + // Commit marker: sequence written last. + WriteU64(&bytes[0], event.sequence); + + return bytes; +} + +bool DecodeEvent(std::span bytes, + Event& output) noexcept { + // Validate checksum. + const std::uint32_t stored_crc = ReadU32(&bytes[kChecksumOffset]); + const std::uint32_t computed_crc = Crc32(&bytes[kCrcStart], kCrcEnd - kCrcStart); + if (stored_crc != computed_crc) { + return false; + } + + output.sequence = ReadU64(&bytes[0]); + output.mach_continuous_time = ReadU64(&bytes[8]); + output.process_token = ReadU64(&bytes[16]); + output.tunnel_session_token = ReadU64(&bytes[24]); + output.event_code = ReadU16(&bytes[32]); + output.flags = ReadU16(&bytes[34]); + output.websocket_generation = ReadU32(&bytes[36]); + output.value_0 = ReadU64(&bytes[40]); + output.value_1 = ReadU64(&bytes[48]); + output.value_2 = ReadU64(&bytes[56]); + + return true; +} + +SnapshotBytes EncodeSnapshot(const Snapshot& snap) noexcept { + SnapshotBytes bytes{}; // zero-init + + // Layout (all little-endian): + // [0..3] magic + // [4..5] schema_version + // [6..7] byte_size (actual used) + // [8..15] write_sequence + // [16..19] checksum (filled after all other fields) + // [20..23] pid + // [24..31] process_token + // [32..39] process_sequence + // [40..47] process_started_mach_time + // [48..55] tunnel_session_token + // [56..63] tunnel_started_mach_time + // [64..67] provider_stop_reason + // [68..69] local_stop_initiator + // [70..71] native_disconnect_code + // [72..73] native_stop_origin + // [74..75] reserved_terminal + // [76..83] snapshot_mach_continuous_time + // [84..91] snapshot_wall_time_ns + // [92..95] flags + // [96..99] lifecycle_state + // [100..103] websocket_generation + // [104..107] reconnect_attempt + // [108..115] physical_footprint_bytes + // [116..123] physical_footprint_peak_bytes + // [124..131] resident_bytes + // [132..135] active_bridges + // [136..139] active_native_clients + // [140..143] native_active_operations + // [144..147] active_reader_coroutines + // [148..151] active_sender_coroutines + // [152..155] active_read_loops + // [156..163] outbound_queued_bytes + // [164..171] outbound_queued_bytes_peak + // [172..179] latest_event_sequence + + constexpr std::uint16_t kUsedBytes = 180; + + WriteU32(&bytes[0], kSnapshotMagic); + WriteU16(&bytes[4], kSchemaVersion); + WriteU16(&bytes[6], kUsedBytes); + WriteU64(&bytes[8], snap.write_sequence); + // checksum at [16..19] filled below + WriteU32(&bytes[20], snap.pid); + WriteU64(&bytes[24], snap.process_token); + WriteU64(&bytes[32], snap.process_sequence); + WriteU64(&bytes[40], snap.process_started_mach_time); + WriteU64(&bytes[48], snap.tunnel_session_token); + WriteU64(&bytes[56], snap.tunnel_started_mach_time); + WriteU32(&bytes[64], snap.provider_stop_reason); + WriteU16(&bytes[68], snap.local_stop_initiator); + WriteU16(&bytes[70], snap.native_disconnect_code); + WriteU16(&bytes[72], snap.native_stop_origin); + // [74..75] reserved_terminal = 0 + WriteU64(&bytes[76], snap.snapshot_mach_continuous_time); + WriteU64(&bytes[84], snap.snapshot_wall_time_ns); + WriteU32(&bytes[92], snap.flags); + WriteU32(&bytes[96], snap.lifecycle_state); + WriteU32(&bytes[100], snap.websocket_generation); + WriteU32(&bytes[104], snap.reconnect_attempt); + WriteU64(&bytes[108], snap.physical_footprint_bytes); + WriteU64(&bytes[116], snap.physical_footprint_peak_bytes); + WriteU64(&bytes[124], snap.resident_bytes); + WriteU32(&bytes[132], snap.active_bridges); + WriteU32(&bytes[136], snap.active_native_clients); + WriteU32(&bytes[140], snap.native_active_operations); + WriteU32(&bytes[144], snap.active_reader_coroutines); + WriteU32(&bytes[148], snap.active_sender_coroutines); + WriteU32(&bytes[152], snap.active_read_loops); + WriteU64(&bytes[156], snap.outbound_queued_bytes); + WriteU64(&bytes[164], snap.outbound_queued_bytes_peak); + WriteU64(&bytes[172], snap.latest_event_sequence); + + // CRC32 over bytes 20..179 (skipping magic/version/size/sequence/checksum). + const std::uint32_t crc = Crc32(&bytes[20], kUsedBytes - 20); + WriteU32(&bytes[16], crc); + + return bytes; +} + +bool DecodeSnapshot(std::span bytes, std::size_t length, + Snapshot& output) noexcept { + if (length < 180) { + return false; + } + + const std::uint32_t magic = ReadU32(&bytes[0]); + if (magic != kSnapshotMagic) { + return false; + } + + const std::uint16_t version = ReadU16(&bytes[4]); + if (version != kSchemaVersion) { + return false; + } + + const std::uint16_t used_bytes = ReadU16(&bytes[6]); + if (used_bytes < 180 || static_cast(used_bytes) > length) { + return false; + } + + // Validate checksum. + const std::uint32_t stored_crc = ReadU32(&bytes[16]); + const std::uint32_t computed_crc = Crc32(&bytes[20], used_bytes - 20); + if (stored_crc != computed_crc) { + return false; + } + + output.write_sequence = ReadU64(&bytes[8]); + output.pid = ReadU32(&bytes[20]); + output.process_token = ReadU64(&bytes[24]); + output.process_sequence = ReadU64(&bytes[32]); + output.process_started_mach_time = ReadU64(&bytes[40]); + output.tunnel_session_token = ReadU64(&bytes[48]); + output.tunnel_started_mach_time = ReadU64(&bytes[56]); + output.provider_stop_reason = ReadU32(&bytes[64]); + output.local_stop_initiator = ReadU16(&bytes[68]); + output.native_disconnect_code = ReadU16(&bytes[70]); + output.native_stop_origin = ReadU16(&bytes[72]); + output.snapshot_mach_continuous_time = ReadU64(&bytes[76]); + output.snapshot_wall_time_ns = ReadU64(&bytes[84]); + output.flags = ReadU32(&bytes[92]); + output.lifecycle_state = ReadU32(&bytes[96]); + output.websocket_generation = ReadU32(&bytes[100]); + output.reconnect_attempt = ReadU32(&bytes[104]); + output.physical_footprint_bytes = ReadU64(&bytes[108]); + output.physical_footprint_peak_bytes = ReadU64(&bytes[116]); + output.resident_bytes = ReadU64(&bytes[124]); + output.active_bridges = ReadU32(&bytes[132]); + output.active_native_clients = ReadU32(&bytes[136]); + output.native_active_operations = ReadU32(&bytes[140]); + output.active_reader_coroutines = ReadU32(&bytes[144]); + output.active_sender_coroutines = ReadU32(&bytes[148]); + output.active_read_loops = ReadU32(&bytes[152]); + output.outbound_queued_bytes = ReadU64(&bytes[156]); + output.outbound_queued_bytes_peak = ReadU64(&bytes[164]); + output.latest_event_sequence = ReadU64(&bytes[172]); + + return true; +} + +} // namespace fptn::diag diff --git a/TunnelDiagnosticsCore/src/DiskFormat.hpp b/TunnelDiagnosticsCore/src/DiskFormat.hpp new file mode 100644 index 0000000..e4a5933 --- /dev/null +++ b/TunnelDiagnosticsCore/src/DiskFormat.hpp @@ -0,0 +1,133 @@ +#pragma once + +#include +#include +#include +#include + +namespace fptn::diag { + +// PR3: flight event codes. Numeric, no strings in the provider. +enum class EventCode : std::uint16_t { + kProcessStarted = 1, + kStartTunnel = 2, + kTunnelConnected = 3, + kStopTunnelEntered = 4, + kTunnelStopped = 5, + kBridgeCreated = 6, + kBridgeStartRequested = 7, + kBridgeConnected = 8, + kBridgeStopRequested = 9, + kBridgeTeardownCompleted = 10, + kReaderStarted = 11, + kReaderStopped = 12, + kSenderStarted = 13, + kSenderStopped = 14, + kReconnectScheduled = 15, + kReconnectStarted = 16, + kTransportDisconnected = 17, + kPathChanged = 18, + kMemorySample = 19, + kMemoryWarning = 20, + kMemoryCritical = 21, + kQueueHighWater = 22, + kInvariantViolation = 23, + kSnapshotWriteFailed = 24, +}; + +// PR3: lifecycle state codes for the snapshot. +enum class LifecycleState : std::uint32_t { + kIdle = 0, + kStarting = 1, + kConnected = 2, + kReasserting = 3, + kWaitingForNetwork = 4, + kStopping = 5, + kFailed = 6, +}; + +// PR3: snapshot flags (bitfield). +enum SnapshotFlags : std::uint32_t { + kFlagShutdownRequested = 1u << 0, + kFlagReasserting = 1u << 1, + kFlagPathSatisfied = 1u << 2, + kFlagReadLoopActive = 1u << 3, + kFlagPacketReadPending = 1u << 4, + kFlagNativeStopCleanupCompleted = 1u << 5, + kFlagRecorderError = 1u << 6, +}; + +// PR3: in-memory event representation (not written directly to disk). +struct Event { + std::uint64_t sequence = 0; + std::uint64_t mach_continuous_time = 0; + std::uint64_t process_token = 0; + std::uint64_t tunnel_session_token = 0; + std::uint16_t event_code = 0; + std::uint16_t flags = 0; + std::uint32_t websocket_generation = 0; + std::uint64_t value_0 = 0; + std::uint64_t value_1 = 0; + std::uint64_t value_2 = 0; +}; + +// PR3: in-memory snapshot representation. +struct Snapshot { + std::uint64_t write_sequence = 0; + std::uint32_t pid = 0; + std::uint64_t process_token = 0; + std::uint64_t process_sequence = 0; + std::uint64_t process_started_mach_time = 0; + std::uint64_t tunnel_session_token = 0; + std::uint64_t tunnel_started_mach_time = 0; + std::uint32_t provider_stop_reason = 0; + std::uint16_t local_stop_initiator = 0; + std::uint16_t native_disconnect_code = 0; + std::uint16_t native_stop_origin = 0; + std::uint64_t snapshot_mach_continuous_time = 0; + std::uint64_t snapshot_wall_time_ns = 0; + std::uint32_t flags = 0; + std::uint32_t lifecycle_state = 0; + std::uint32_t websocket_generation = 0; + std::uint32_t reconnect_attempt = 0; + std::uint64_t physical_footprint_bytes = 0; + std::uint64_t physical_footprint_peak_bytes = 0; + std::uint64_t resident_bytes = 0; + std::uint32_t active_bridges = 0; + std::uint32_t active_native_clients = 0; + std::uint32_t native_active_operations = 0; + std::uint32_t active_reader_coroutines = 0; + std::uint32_t active_sender_coroutines = 0; + std::uint32_t active_read_loops = 0; + std::uint64_t outbound_queued_bytes = 0; + std::uint64_t outbound_queued_bytes_peak = 0; + std::uint64_t latest_event_sequence = 0; +}; + +// PR3: disk format constants. +inline constexpr std::uint32_t kFlightRingMagic = 0x46505452; // "FPTR" +inline constexpr std::uint32_t kSnapshotMagic = 0x4650544E; // "FPTN" +inline constexpr std::uint16_t kSchemaVersion = 1; +inline constexpr std::size_t kFlightRecordSize = 72; +inline constexpr std::size_t kFlightRingHeaderSize = 64; +inline constexpr std::size_t kFlightRingCapacity = 120; +inline constexpr std::size_t kSnapshotMaxSize = 512; +inline constexpr std::size_t kSnapshotSlotCount = 2; + +// PR3: CRC32 (bitwise, no lookup table — records are tiny). +std::uint32_t Crc32(const std::byte* data, std::size_t length) noexcept; + +// PR3: explicit little-endian encode/decode. Never dump structs. +using RecordBytes = std::array; + +RecordBytes EncodeEvent(const Event& event) noexcept; +bool DecodeEvent(std::span bytes, + Event& output) noexcept; + +using SnapshotBytes = std::array; + +SnapshotBytes EncodeSnapshot(const Snapshot& snap) noexcept; +bool DecodeSnapshot(std::span bytes, std::size_t length, + Snapshot& output) noexcept; + +} // namespace fptn::diag diff --git a/TunnelDiagnosticsCore/src/FlightRecorder.cpp b/TunnelDiagnosticsCore/src/FlightRecorder.cpp new file mode 100644 index 0000000..4b81170 --- /dev/null +++ b/TunnelDiagnosticsCore/src/FlightRecorder.cpp @@ -0,0 +1,167 @@ +#include "FlightRecorder.hpp" + +#include +#include + +namespace fptn::diag { + +namespace { + +// Ring file layout: +// [0..63] FptnFlightRingHeader (64 bytes) +// [64..] 120 records × 72 bytes = 8,640 bytes +// Total: 8,704 bytes + +inline constexpr std::size_t kRingFileSize = + kFlightRingHeaderSize + kFlightRingCapacity * kFlightRecordSize; + +std::size_t SlotOffset(std::uint64_t sequence) noexcept { + const std::size_t slot = + static_cast((sequence - 1) % kFlightRingCapacity); + return kFlightRingHeaderSize + slot * kFlightRecordSize; +} + +} // namespace + +FlightRecorder* FlightRecorder::Open(const char* path) noexcept { + auto* rec = new (std::nothrow) FlightRecorder(); + if (!rec) return nullptr; + + const int fd = ::open(path, O_RDWR | O_CREAT, 0644); + if (fd < 0) { + delete rec; + return nullptr; + } + rec->file_.Reset(fd); + + // Check if the file already has a valid header. + if (!rec->InitFromExisting()) { + if (!rec->WriteHeader()) { + delete rec; + return nullptr; + } + } + + return rec; +} + +bool FlightRecorder::InitFromExisting() noexcept { + // Read the header. + std::array header_bytes{}; + if (!PreadAll(file_.Get(), header_bytes.data(), header_bytes.size(), 0)) { + return false; + } + + // Validate magic. + const std::uint32_t magic = + static_cast(static_cast(header_bytes[0])) | + (static_cast(static_cast(header_bytes[1])) << 8) | + (static_cast(static_cast(header_bytes[2])) << 16) | + (static_cast(static_cast(header_bytes[3])) << 24); + if (magic != kFlightRingMagic) { + return false; + } + + // Scan all slots for the highest valid sequence. + std::uint64_t max_sequence = 0; + for (std::size_t slot = 0; slot < kFlightRingCapacity; ++slot) { + const std::size_t offset = kFlightRingHeaderSize + slot * kFlightRecordSize; + std::array record_bytes{}; + if (!PreadAll(file_.Get(), record_bytes.data(), record_bytes.size(), + static_cast(offset))) { + continue; + } + + Event event; + if (DecodeEvent(record_bytes, event)) { + if (event.sequence > max_sequence) { + max_sequence = event.sequence; + } + } + } + + next_sequence_ = max_sequence + 1; + return true; +} + +bool FlightRecorder::WriteHeader() noexcept { + std::array header{}; + + // magic + header[0] = static_cast(kFlightRingMagic & 0xFF); + header[1] = static_cast((kFlightRingMagic >> 8) & 0xFF); + header[2] = static_cast((kFlightRingMagic >> 16) & 0xFF); + header[3] = static_cast((kFlightRingMagic >> 24) & 0xFF); + // schema_version + header[4] = static_cast(kSchemaVersion & 0xFF); + header[5] = static_cast((kSchemaVersion >> 8) & 0xFF); + // header_size + header[6] = static_cast(kFlightRingHeaderSize & 0xFF); + header[7] = static_cast((kFlightRingHeaderSize >> 8) & 0xFF); + // record_size + header[8] = static_cast(kFlightRecordSize & 0xFF); + header[9] = static_cast((kFlightRecordSize >> 8) & 0xFF); + // capacity + header[10] = static_cast(kFlightRingCapacity & 0xFF); + header[11] = static_cast((kFlightRingCapacity >> 8) & 0xFF); + // checksum over bytes 0..11 + const std::uint32_t crc = Crc32(header.data(), 12); + header[12] = static_cast(crc & 0xFF); + header[13] = static_cast((crc >> 8) & 0xFF); + header[14] = static_cast((crc >> 16) & 0xFF); + header[15] = static_cast((crc >> 24) & 0xFF); + // rest is reserved (zero) + + if (!PwriteAll(file_.Get(), header.data(), header.size(), 0)) { + return false; + } + + // Extend file to full ring size. + if (::ftruncate(file_.Get(), static_cast(kRingFileSize)) != 0) { + return false; + } + + next_sequence_ = 1; + return true; +} + +std::uint64_t FlightRecorder::Record(const Event& event, + bool synchronize) noexcept { + std::lock_guard lock(mutex_); + + const std::uint64_t seq = next_sequence_; + + // Build the record with the assigned sequence. + Event committed = event; + committed.sequence = seq; + + const RecordBytes bytes = EncodeEvent(committed); + const std::size_t offset = SlotOffset(seq); + + // Write payload (bytes 8..71) first, then sequence (bytes 0..7) + // as the commit marker. + if (!PwriteAll(file_.Get(), &bytes[8], kFlightRecordSize - 8, + static_cast(offset + 8))) { + return 0; + } + + // Commit: write sequence. + if (!PwriteAll(file_.Get(), &bytes[0], 8, + static_cast(offset))) { + return 0; + } + + if (synchronize) { + ::fsync(file_.Get()); + } + + next_sequence_ = seq + 1; + return seq; +} + +bool FlightRecorder::Flush() noexcept { + std::lock_guard lock(mutex_); + return ::fsync(file_.Get()) == 0; +} + +} // namespace fptn::diag diff --git a/TunnelDiagnosticsCore/src/FlightRecorder.hpp b/TunnelDiagnosticsCore/src/FlightRecorder.hpp new file mode 100644 index 0000000..294014d --- /dev/null +++ b/TunnelDiagnosticsCore/src/FlightRecorder.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include "DiskFormat.hpp" +#include "PosixFile.hpp" + +namespace fptn::diag { + +// PR3: bounded flight recorder. Fixed 8,704-byte ring file. +// Allocation-free on the Record() path. Thread-safe via internal mutex. +class FlightRecorder final { + public: + // Opens or creates the ring file. Scans existing records to resume + // from the highest valid sequence (no truncation of old evidence). + static FlightRecorder* Open(const char* path) noexcept; + + ~FlightRecorder() noexcept = default; + + FlightRecorder(FlightRecorder&&) noexcept = default; + FlightRecorder& operator=(FlightRecorder&&) noexcept = default; + FlightRecorder(const FlightRecorder&) = delete; + FlightRecorder& operator=(const FlightRecorder&) = delete; + + // Records an event. Returns the assigned sequence (0 on failure). + // If synchronize is true, calls fsync after the write. + std::uint64_t Record(const Event& event, bool synchronize) noexcept; + + // Flushes the ring file to disk. + bool Flush() noexcept; + + private: + FlightRecorder() noexcept = default; + + bool InitFromExisting() noexcept; + bool WriteHeader() noexcept; + + PosixFile file_; + std::mutex mutex_; + std::uint64_t next_sequence_ = 1; +}; + +} // namespace fptn::diag diff --git a/TunnelDiagnosticsCore/src/LifecycleStore.cpp b/TunnelDiagnosticsCore/src/LifecycleStore.cpp new file mode 100644 index 0000000..7493885 --- /dev/null +++ b/TunnelDiagnosticsCore/src/LifecycleStore.cpp @@ -0,0 +1,82 @@ +#include "LifecycleStore.hpp" + +#include + +namespace fptn::diag { + +namespace { + +inline constexpr std::size_t kSnapshotFileSize = + kSnapshotMaxSize * kSnapshotSlotCount; + +std::size_t SlotOffset(int slot) noexcept { + return static_cast(slot) * kSnapshotMaxSize; +} + +} // namespace + +LifecycleStore* LifecycleStore::Open(const char* path) noexcept { + auto* store = new (std::nothrow) LifecycleStore(); + if (!store) return nullptr; + + const int fd = ::open(path, O_RDWR | O_CREAT, 0644); + if (fd < 0) { + delete store; + return nullptr; + } + store->file_.Reset(fd); + + // Extend to full size. + if (::ftruncate(fd, static_cast(kSnapshotFileSize)) != 0) { + delete store; + return nullptr; + } + + // Scan both slots for the highest valid sequence. + std::uint64_t max_seq = 0; + int max_slot = 0; + for (int slot = 0; slot < static_cast(kSnapshotSlotCount); ++slot) { + std::array bytes{}; + if (!PreadAll(fd, bytes.data(), bytes.size(), + static_cast(SlotOffset(slot)))) { + continue; + } + Snapshot snap; + if (DecodeSnapshot(bytes, kSnapshotMaxSize, snap)) { + if (snap.write_sequence > max_seq) { + max_seq = snap.write_sequence; + max_slot = slot; + } + } + } + + store->next_sequence_ = max_seq + 1; + store->next_slot_ = (max_slot + 1) % static_cast(kSnapshotSlotCount); + + return store; +} + +bool LifecycleStore::Write(const Snapshot& snap, bool synchronize) noexcept { + std::lock_guard lock(mutex_); + + Snapshot committed = snap; + committed.write_sequence = next_sequence_; + + const SnapshotBytes bytes = EncodeSnapshot(committed); + const std::size_t offset = SlotOffset(next_slot_); + + if (!PwriteAll(file_.Get(), bytes.data(), kSnapshotMaxSize, + static_cast(offset))) { + return false; + } + + if (synchronize) { + ::fsync(file_.Get()); + } + + next_sequence_ += 1; + next_slot_ = (next_slot_ + 1) % static_cast(kSnapshotSlotCount); + return true; +} + +} // namespace fptn::diag diff --git a/TunnelDiagnosticsCore/src/LifecycleStore.hpp b/TunnelDiagnosticsCore/src/LifecycleStore.hpp new file mode 100644 index 0000000..78033b6 --- /dev/null +++ b/TunnelDiagnosticsCore/src/LifecycleStore.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include "DiskFormat.hpp" +#include "PosixFile.hpp" + +namespace fptn::diag { + +// PR3: double-buffered lifecycle snapshot store. +// Two 512-byte slots (A and B). Writes alternate; a torn write +// cannot destroy both copies. Reader picks the valid slot with +// the highest write_sequence. +class LifecycleStore final { + public: + static LifecycleStore* Open(const char* path) noexcept; + + ~LifecycleStore() noexcept = default; + + LifecycleStore(LifecycleStore&&) noexcept = default; + LifecycleStore& operator=(LifecycleStore&&) noexcept = default; + LifecycleStore(const LifecycleStore&) = delete; + LifecycleStore& operator=(const LifecycleStore&) = delete; + + // Writes a snapshot to the older slot. If synchronize is true, + // calls fsync after the write. + bool Write(const Snapshot& snap, bool synchronize) noexcept; + + private: + LifecycleStore() noexcept = default; + + PosixFile file_; + std::mutex mutex_; + std::uint64_t next_sequence_ = 1; + int next_slot_ = 0; // alternates 0/1 +}; + +} // namespace fptn::diag diff --git a/TunnelDiagnosticsCore/src/PosixFile.hpp b/TunnelDiagnosticsCore/src/PosixFile.hpp new file mode 100644 index 0000000..e2aca53 --- /dev/null +++ b/TunnelDiagnosticsCore/src/PosixFile.hpp @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include + +#include + +namespace fptn::diag { + +// PR3: move-only RAII file descriptor. Closes automatically. +// No exceptions, no allocation. +class PosixFile final { + public: + PosixFile() noexcept = default; + + explicit PosixFile(int fd) noexcept : fd_(fd) {} + + ~PosixFile() noexcept { + if (fd_ >= 0) { + ::close(fd_); + } + } + + PosixFile(PosixFile&& other) noexcept + : fd_(std::exchange(other.fd_, -1)) {} + + PosixFile& operator=(PosixFile&& other) noexcept { + if (this != &other) { + Reset(std::exchange(other.fd_, -1)); + } + return *this; + } + + PosixFile(const PosixFile&) = delete; + PosixFile& operator=(const PosixFile&) = delete; + + int Get() const noexcept { return fd_; } + bool IsValid() const noexcept { return fd_ >= 0; } + + void Reset(int newFd = -1) noexcept { + if (fd_ >= 0) { + ::close(fd_); + } + fd_ = newFd; + } + + int Release() noexcept { return std::exchange(fd_, -1); } + + private: + int fd_ = -1; +}; + +// PR3: pwrite with EINTR retry and partial-write handling. +inline bool PwriteAll(int fd, const void* buf, std::size_t count, + off_t offset) noexcept { + const auto* ptr = static_cast(buf); + std::size_t written = 0; + while (written < count) { + const ssize_t n = ::pwrite(fd, ptr + written, count - written, + offset + static_cast(written)); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + written += static_cast(n); + } + return true; +} + +// PR3: pread with EINTR retry and partial-read handling. +inline bool PreadAll(int fd, void* buf, std::size_t count, + off_t offset) noexcept { + auto* ptr = static_cast(buf); + std::size_t total = 0; + while (total < count) { + const ssize_t n = ::pread(fd, ptr + total, count - total, + offset + static_cast(total)); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + if (n == 0) break; // EOF + total += static_cast(n); + } + return total == count; +} + +} // namespace fptn::diag diff --git a/project.yml b/project.yml index 2c75bf8..63e9342 100644 --- a/project.yml +++ b/project.yml @@ -65,10 +65,41 @@ targetTemplates: NSExtensionPointIdentifier: com.apple.networkextension.packet-tunnel NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).PacketTunnelProvider + # PR3: shared diagnostics core static library + DiagnosticsCoreBase: + type: library.static + sources: + - path: TunnelDiagnosticsCore/src + headerVisibility: project + - path: TunnelDiagnosticsCore/include + headerVisibility: public + buildPhase: headers + settings: + base: + CLANG_CXX_LANGUAGE_STANDARD: "c++23" + GCC_ENABLE_CPP_EXCEPTIONS: NO + GCC_ENABLE_CPP_RTTI: NO + DEFINES_MODULE: YES + PRODUCT_MODULE_NAME: FptnTunnelDiagnosticsCore + PRODUCT_NAME: FptnTunnelDiagnosticsCore + # ── Targets ──────────────────────────────────────────────────────────────────── targets: + # ── PR3: Diagnostics Core static libraries ────────────────────────────────── + FptnTunnelDiagnosticsCore-iOS: + templates: [DiagnosticsCoreBase] + platform: iOS + + FptnTunnelDiagnosticsCore-macOS: + templates: [DiagnosticsCoreBase] + platform: macOS + + FptnTunnelDiagnosticsCore-tvOS: + templates: [DiagnosticsCoreBase] + platform: tvOS + # ── iOS App ────────────────────────────────────────────────────────────────── FptnVPN: templates: [AppBase] @@ -92,6 +123,7 @@ targets: FRAMEWORK_SEARCH_PATHS: $(PROJECT_DIR)/FptnVPN/Cpp OTHER_LDFLAGS: [-ObjC] SWIFT_OBJC_BRIDGING_HEADER: FptnVPN/Cpp/FptnVPN-Bridging-Header.h + HEADER_SEARCH_PATHS: $(inherited) $(PROJECT_DIR)/TunnelDiagnosticsCore/include info: path: FptnVPN/Info.plist properties: @@ -132,6 +164,7 @@ targets: dependencies: - target: FptnVPNTunnel embed: true + - target: FptnTunnelDiagnosticsCore-iOS - package: FptnShared product: FptnSharedCore - package: FptnShared @@ -173,6 +206,7 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: net.mrmidi.FptnVPN.FptnVPNTunnel FRAMEWORK_SEARCH_PATHS: $(PROJECT_DIR)/FptnVPN/Cpp SWIFT_OBJC_BRIDGING_HEADER: FptnVPNTunnel/Cpp/FptnVPNTunnel-Bridging-Header.h + HEADER_SEARCH_PATHS: $(inherited) $(PROJECT_DIR)/TunnelDiagnosticsCore/include configs: # PR-1 (Measurement Safety): the Measurement configuration disables # legacy JSONL diagnostics, forces warning-only logging, and uses a @@ -190,6 +224,7 @@ targets: com.apple.security.application-groups: - group.net.mrmidi.FptnVPN dependencies: + - target: FptnTunnelDiagnosticsCore-iOS - package: swift-log product: Logging - framework: FptnVPN/Cpp/fptn_native_lib.framework