diff --git a/FptnVPN/Services/TunnelDiagnosticsDecoder.swift b/FptnVPN/Services/TunnelDiagnosticsDecoder.swift new file mode 100644 index 0000000..93856e1 --- /dev/null +++ b/FptnVPN/Services/TunnelDiagnosticsDecoder.swift @@ -0,0 +1,302 @@ +import Foundation + +// PR4b: app-side decoder for binary flight recorder + lifecycle snapshot. +// Injectable paths and clocks for testability. Unknown-safe: preserves +// raw numeric codes alongside optional typed interpretations. +struct TunnelDiagnosticsDecoder: Sendable { + let ringPath: String + let snapshotPath: String + let continuousTime: @Sendable () -> UInt64 + let wallTime: @Sendable () -> Date + let ticksToSeconds: @Sendable (UInt64) -> TimeInterval + + init( + ringPath: String, + snapshotPath: String, + continuousTime: @escaping @Sendable () -> UInt64 = { mach_continuous_time() }, + wallTime: @escaping @Sendable () -> Date = { Date() }, + ticksToSeconds: @escaping @Sendable (UInt64) -> TimeInterval = TunnelDiagnosticsDecoder.defaultTicksToSeconds + ) { + self.ringPath = ringPath + self.snapshotPath = snapshotPath + self.continuousTime = continuousTime + self.wallTime = wallTime + self.ticksToSeconds = ticksToSeconds + } + + static func defaultTicksToSeconds(_ ticks: UInt64) -> TimeInterval { + var info = mach_timebase_info_data_t() + mach_timebase_info(&info) + return Double(ticks) * Double(info.numer) / Double(info.denom) / 1_000_000_000 + } + + static var production: TunnelDiagnosticsDecoder? { + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.net.mrmidi.FptnVPN" + ) else { return nil } + let diagDir = container.appendingPathComponent("diagnostics", isDirectory: true) + return TunnelDiagnosticsDecoder( + ringPath: diagDir.appendingPathComponent("flight-ring.bin").path, + snapshotPath: diagDir.appendingPathComponent("lifecycle-snapshot.bin").path + ) + } + + // MARK: - Decoded types + + struct DecodedFlightEvent: Sendable { + let sequence: UInt64 + let monotonicTime: UInt64 + let processToken: UInt64 + let sessionToken: UInt64 + let rawEventCode: UInt16 + let flags: UInt16 + let generation: UInt32 + let value0: UInt64 + let value1: UInt64 + let value2: UInt64 + let timestamp: Date? + } + + struct DecodedSnapshot: Sendable { + let writeSequence: UInt64 + let pid: UInt32 + let processToken: UInt64 + let processSequence: UInt64 + let processStartedMachTime: UInt64 + let sessionToken: UInt64 + let tunnelStartedMachTime: UInt64 + let lifecycleState: UInt32 + let providerStopReason: UInt32 + let localStopInitiator: UInt16 + let nativeDisconnectCode: UInt16 + let nativeStopOrigin: UInt16 + let flags: UInt32 + let websocketGeneration: UInt32 + let reconnectAttempt: UInt32 + let footprintBytes: UInt64 + let footprintPeakBytes: UInt64 + let residentBytes: UInt64 + let activeBridges: UInt32 + let activeNativeClients: UInt32 + let nativeActiveOperations: UInt32 + let activeReaderCoroutines: UInt32 + let activeSenderCoroutines: UInt32 + let activeReadLoops: UInt32 + let outboundQueuedBytes: UInt64 + let outboundQueuedBytesPeak: UInt64 + let monotonicTime: UInt64 + let wallTimeNs: UInt64 + let latestEventSequence: UInt64 + + var isControlledStop: Bool { + // 1 = appDisconnect, 3 = systemStop (TunnelStopInitiator) + localStopInitiator == 1 || localStopInitiator == 3 + } + } + + // MARK: - Reading + + func readFlightEvents() -> (status: FptnReadStatus, events: [DecodedFlightEvent]) { + let capacity = fptn_flight_recorder_capacity() + var events = [FptnDecodedFlightEvent]( + repeating: FptnDecodedFlightEvent(), + count: capacity + ) + var count: Int = 0 + let status = events.withUnsafeMutableBufferPointer { buffer in + fptn_flight_recorder_read_valid( + ringPath, + buffer.baseAddress, + capacity, + &count + ) + } + + guard status == FPTN_READ_OK, count > 0 else { + return (status: status, events: []) + } + + // Build per-process wall-time anchors from processStarted events. + var anchors: [UInt64: (machTime: UInt64, wallDate: Date)] = [:] + for i in 0.. 0 { + anchors[events[i].process_token] = ( + machTime: events[i].mach_continuous_time, + wallDate: Date(timeIntervalSince1970: TimeInterval(wallNs) / 1_000_000_000) + ) + } + } + } + + let decoded = (0..= anchor.machTime { + let deltaTicks = e.mach_continuous_time - anchor.machTime + timestamp = anchor.wallDate.addingTimeInterval(ticksToSeconds(deltaTicks)) + } + return DecodedFlightEvent( + sequence: e.sequence, + monotonicTime: e.mach_continuous_time, + processToken: e.process_token, + sessionToken: e.tunnel_session_token, + rawEventCode: e.event_code, + flags: e.flags, + generation: e.websocket_generation, + value0: e.value_0, + value1: e.value_1, + value2: e.value_2, + timestamp: timestamp + ) + } + + return (status: status, events: decoded) + } + + func readLifecycleSnapshot() -> DecodedSnapshot? { + var snap = FptnDecodedLifecycleSnapshot() + let status = fptn_lifecycle_store_read_latest(snapshotPath, &snap) + guard status == FPTN_READ_OK else { return nil } + return DecodedSnapshot( + writeSequence: snap.write_sequence, + pid: snap.pid, + processToken: snap.process_token, + processSequence: snap.process_sequence, + processStartedMachTime: snap.process_started_mach_time, + sessionToken: snap.tunnel_session_token, + tunnelStartedMachTime: snap.tunnel_started_mach_time, + lifecycleState: snap.lifecycle_state, + providerStopReason: snap.provider_stop_reason, + localStopInitiator: snap.local_stop_initiator, + nativeDisconnectCode: snap.native_disconnect_code, + nativeStopOrigin: snap.native_stop_origin, + flags: snap.flags, + websocketGeneration: snap.websocket_generation, + reconnectAttempt: snap.reconnect_attempt, + footprintBytes: snap.physical_footprint_bytes, + footprintPeakBytes: snap.physical_footprint_peak_bytes, + residentBytes: snap.resident_bytes, + activeBridges: snap.active_bridges, + activeNativeClients: snap.active_native_clients, + nativeActiveOperations: snap.native_active_operations, + activeReaderCoroutines: snap.active_reader_coroutines, + activeSenderCoroutines: snap.active_sender_coroutines, + activeReadLoops: snap.active_read_loops, + outboundQueuedBytes: snap.outbound_queued_bytes, + outboundQueuedBytesPeak: snap.outbound_queued_bytes_peak, + monotonicTime: snap.snapshot_mach_continuous_time, + wallTimeNs: snap.snapshot_wall_time_ns, + latestEventSequence: snap.latest_event_sequence + ) + } + + // MARK: - Staleness + + func ageSeconds(of snapshot: DecodedSnapshot) -> TimeInterval? { + let now = continuousTime() + if now >= snapshot.monotonicTime, snapshot.monotonicTime > 0 { + return ticksToSeconds(now - snapshot.monotonicTime) + } + if snapshot.wallTimeNs > 0 { + let wallDate = Date(timeIntervalSince1970: TimeInterval(snapshot.wallTimeNs) / 1_000_000_000) + let delta = wallTime().timeIntervalSince(wallDate) + return max(0, delta) + } + return nil + } + + // MARK: - Failure report + + func makeFailureReport(disconnectReason: String) -> String { + var lines: [String] = ["=== Failure Report: \(disconnectReason) ==="] + + guard let snap = readLifecycleSnapshot() else { + lines.append("Snapshot: unavailable") + return lines.joined(separator: "\n") + } + + let age = ageSeconds(of: snap).map { String(format: "%.0fs", $0) } ?? "unknown" + lines.append("Snapshot: seq=\(snap.writeSequence) pid=\(snap.pid) state=\(snap.lifecycleState) age=\(age)") + lines.append(" footprint=\(snap.footprintBytes / 1024 / 1024)MB peak=\(snap.footprintPeakBytes / 1024 / 1024)MB") + lines.append(" stopInitiator=\(snap.localStopInitiator) disconnectCode=\(snap.nativeDisconnectCode) stopOrigin=\(snap.nativeStopOrigin)") + lines.append(" bridges=\(snap.activeBridges) clients=\(snap.activeNativeClients) ops=\(snap.nativeActiveOperations)") + + // Filter events to the failed process/session. + let result = readFlightEvents() + let relevant = result.events.filter { + $0.processToken == snap.processToken && + ($0.sessionToken == snap.sessionToken || $0.sessionToken == 0) && + !isHiddenByClearWatermark($0) + } + lines.append("Events (this session): \(relevant.count)") + for event in relevant.suffix(20) { + let ts = event.timestamp.map { "\($0)" } ?? "t=\(event.monotonicTime)" + lines.append(" [\(event.sequence)] code=\(event.rawEventCode) gen=\(event.generation) v0=\(event.value0) v1=\(event.value1) v2=\(event.value2) \(ts)") + } + + return lines.joined(separator: "\n") + } + + // MARK: - Logical clear + + struct ClearWatermark: Codable { + let processToken: UInt64 + let latestEventSequence: UInt64 + let snapshotWriteSequence: UInt64 + } + + private var clearWatermarkPath: String { + (ringPath as NSString).deletingLastPathComponent + "/clear-watermark.json" + } + + func readClearWatermark() -> ClearWatermark? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: clearWatermarkPath)) else { return nil } + return try? JSONDecoder().decode(ClearWatermark.self, from: data) + } + + func writeClearWatermark() { + guard let snap = readLifecycleSnapshot() else { return } + let watermark = ClearWatermark( + processToken: snap.processToken, + latestEventSequence: snap.latestEventSequence, + snapshotWriteSequence: snap.writeSequence + ) + guard let data = try? JSONEncoder().encode(watermark) else { return } + try? data.write(to: URL(fileURLWithPath: clearWatermarkPath)) + } + + private func isHiddenByClearWatermark(_ event: DecodedFlightEvent) -> Bool { + guard let wm = readClearWatermark() else { return false } + return event.processToken == wm.processToken && + event.sequence <= wm.latestEventSequence + } + + // MARK: - Export + + func exportText() -> String { + var lines: [String] = ["=== Binary Diagnostics ==="] + + if let snap = readLifecycleSnapshot() { + let age = ageSeconds(of: snap).map { String(format: "%.0fs", $0) } ?? "unknown" + lines.append("Snapshot: seq=\(snap.writeSequence) pid=\(snap.pid) state=\(snap.lifecycleState) age=\(age)") + lines.append(" footprint=\(snap.footprintBytes / 1024 / 1024)MB peak=\(snap.footprintPeakBytes / 1024 / 1024)MB resident=\(snap.residentBytes / 1024 / 1024)MB") + lines.append(" bridges=\(snap.activeBridges) clients=\(snap.activeNativeClients) ops=\(snap.nativeActiveOperations) queued=\(snap.outboundQueuedBytes)") + lines.append(" stopInitiator=\(snap.localStopInitiator) disconnectCode=\(snap.nativeDisconnectCode) stopOrigin=\(snap.nativeStopOrigin)") + } else { + lines.append("Snapshot: unavailable") + } + + let result = readFlightEvents() + let visible = result.events.filter { !isHiddenByClearWatermark($0) } + lines.append("Events: \(visible.count) (status=\(result.status))") + for event in visible.suffix(30) { + let ts = event.timestamp.map { "\($0)" } ?? "t=\(event.monotonicTime)" + lines.append(" [\(event.sequence)] proc=\(event.processToken) sess=\(event.sessionToken) code=\(event.rawEventCode) gen=\(event.generation) v0=\(event.value0) v1=\(event.value1) v2=\(event.value2) flags=\(event.flags) \(ts)") + } + + return lines.joined(separator: "\n") + } +} diff --git a/FptnVPN/Services/VPNService.swift b/FptnVPN/Services/VPNService.swift index d6e5758..9444a8f 100644 --- a/FptnVPN/Services/VPNService.swift +++ b/FptnVPN/Services/VPNService.swift @@ -9,6 +9,8 @@ import Combine import Darwin @preconcurrency import NetworkExtension +// Private IPC types — will migrate to FptnShared 0.3.0 once +// ServerBootstrapProbing protocol alignment is resolved. private enum TunnelControlAction: String, Codable, Sendable { case setLogLevel = "set_log_level" case ping @@ -21,11 +23,7 @@ private struct TunnelControlMessage: Codable, Sendable { let logLevel: String? let initiator: String? - init( - action: TunnelControlAction, - logLevel: String? = nil, - initiator: String? = nil - ) { + init(action: TunnelControlAction, logLevel: String? = nil, initiator: String? = nil) { self.action = action self.logLevel = logLevel self.initiator = initiator @@ -703,8 +701,9 @@ final class VPNService: ObservableObject { "Tunnel last disconnect error domain=\(nsError.domain) code=\(nsError.code) reason=\(reason) description=\(nsError.localizedDescription)" ) if reason == "plugin_failed" { - let report = TunnelDiagnosticsStore.shared.makeProviderFailureReport(disconnectReason: reason) - logger.warning("\(report.summaryLine)") + // PR4b: use binary decoder failure report (filtered to failed session). + let report = TunnelDiagnosticsDecoder.production?.makeFailureReport(disconnectReason: reason) ?? "binary diagnostics unavailable" + logger.warning("plugin_failed:\n\(report)") } } } @@ -722,30 +721,32 @@ final class VPNService: ObservableObject { } // PR2: evaluate whether to reconnect, defer, or suppress. + // PR4b: fallback decision using binary lifecycle snapshot. 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) + guard let decoder = TunnelDiagnosticsDecoder.production, + let snapshot = decoder.readLifecycleSnapshot() else { + return .reconnectNow } - // 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)) - } + let age = decoder.ageSeconds(of: snapshot) + let controlledStopTrustWindow: TimeInterval = 45 - // 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" { + // Controlled stop suppresses immediately, but only when the + // snapshot is recent enough to trust as belonging to this disconnect. + if snapshot.isControlledStop, + let age, + age < controlledStopTrustWindow { return .doNotReconnect } + // Fresh snapshot: provider is alive and handling its own reconnect. + if let age, age < 45 { + return .recheckAfter(max(1, 45 - age)) + } + return .reconnectNow } diff --git a/FptnVPN/ViewModels/LogsViewModel.swift b/FptnVPN/ViewModels/LogsViewModel.swift index d411123..4836844 100644 --- a/FptnVPN/ViewModels/LogsViewModel.swift +++ b/FptnVPN/ViewModels/LogsViewModel.swift @@ -86,6 +86,8 @@ final class LogsViewModel: ObservableObject { RingLogSink.app.clear() RingLogSink.tunnel.clear() TunnelDiagnosticsStore.shared.clear() + // PR4b: write logical clear watermark instead of deleting binary files. + TunnelDiagnosticsDecoder.production?.writeClearWatermark() entries.removeAll() lastAppOffset = 0 lastTunnelOffset = 0 @@ -102,7 +104,8 @@ final class LogsViewModel: ObservableObject { func exportFilteredText() -> String { let logText = filteredEntries.map(\.raw).joined(separator: "\n") - let diagnosticsText = TunnelDiagnosticsStore.shared.exportDiagnosticsText() + // PR4b: use binary decoder for diagnostics export. + let diagnosticsText = TunnelDiagnosticsDecoder.production?.exportText() ?? "" return redactSensitive([logText, diagnosticsText].filter { !$0.isEmpty }.joined(separator: "\n\n")) } diff --git a/FptnVPNTunnel/PacketTunnelProvider.swift b/FptnVPNTunnel/PacketTunnelProvider.swift index 22a6f52..6d486ad 100644 --- a/FptnVPNTunnel/PacketTunnelProvider.swift +++ b/FptnVPNTunnel/PacketTunnelProvider.swift @@ -280,6 +280,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { private var lastStopReasonRawValue: Int? private var counters = PacketCounters() private var lastMemoryWarningAt: Date? + // PR4b: track last recorded pressure level so warning→emergency + // transitions are always recorded even inside the log throttle. + private var lastRecordedMemoryLevel: TunnelMemoryPressureLevel = .normal private var readBackpressureUntil: Date? private var readBackpressureWorkItem: DispatchWorkItem? private var consecutiveSendFailureBatches = 0 @@ -776,18 +779,22 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { // 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)" } ?? "-" + let disconnectCode = nativeStatus?.disconnectCode.rawValue ?? 0 + let stopOrigin = nativeStatus?.stopOrigin.rawValue ?? 0 + let activeOps = nativeStatus?.activeOperations ?? 0 logger.warning( - "Tunnel websocket disconnected generation=\(generation) was_connected=\(wasConnected) reason=\(reason) stop_initiator=\(stopInitiator?.rawValue ?? "-") disconnect_code=\(disconnectCodeStr) stop_origin=\(stopOriginStr) active_ops=\(activeOpsStr) \(activityDiagnosticsDescription())" + "Tunnel websocket disconnected generation=\(generation) was_connected=\(wasConnected) reason=\(reason) stop_initiator=\(stopInitiator?.rawValue ?? "-") disconnect_code=\(disconnectCode) stop_origin=\(stopOrigin) active_ops=\(activeOps) \(activityDiagnosticsDescription())" ) 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)", + message: "transport_disconnected generation=\(generation) was_connected=\(wasConnected) reason=\(reason) stop_initiator=\(stopInitiator?.rawValue ?? "-") disconnect_code=\(disconnectCode) stop_origin=\(stopOrigin) active_ops=\(activeOps)\(pathHandoffHint)", generation: generation, - flightEvent: .transportDisconnected + flightEvent: .transportDisconnected, + flightFlags: wasConnected ? 1 : 0, + value0: UInt64(disconnectCode), + value1: UInt64(stopOrigin), + value2: UInt64(activeOps) ) updateDiagnosticsHeartbeat(lastEvent: "transport_disconnected") #if FPTN_SIGNPOSTS @@ -966,7 +973,10 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { generation: generation, reconnectAttempt: nextAttempt, pathSatisfied: true, - flightEvent: .reconnectScheduled + flightEvent: .reconnectScheduled, + flightFlags: 1, + value0: UInt64(nextAttempt), + value1: UInt64(delaySeconds) ) updateDiagnosticsHeartbeat(lastEvent: "reconnect_scheduled") @@ -1993,6 +2003,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { // inside TunnelDiagnosticsStore. // PR3: binary-only event recording. No JSONL, no string evaluation. // flightEvent is mandatory — explicit numeric codes are the contract. + // PR4b: value0/value1/value2 carry event-specific numeric payloads. + // flags bit 0 = wasConnected (transportDisconnected), pathSatisfied (reconnect). private func recordProviderEvent( category: String, message: @autoclosure () -> String, @@ -2000,12 +2012,20 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { generation: Int? = nil, reconnectAttempt: Int? = nil, pathSatisfied: Bool? = nil, - flightEvent: TunnelFlightEventCode + flightEvent: TunnelFlightEventCode, + flightFlags: UInt16 = 0, + value0: UInt64 = 0, + value1: UInt64 = 0, + value2: UInt64 = 0 ) { if let seq = flightRecorder?.recordForSession( flightEvent, sessionToken: tunnelSessionToken, - generation: UInt32(generation ?? 0) + generation: UInt32(generation ?? 0), + flags: flightFlags, + value0: value0, + value1: value1, + value2: value2 ), seq > 0 { diagnosticsLock.lock() latestEventSequence = seq @@ -2157,8 +2177,28 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { ) { switch memory.level { case .normal: + lastRecordedMemoryLevel = .normal return case .warning, .emergency: + // PR4b: record binary event on level change, independent + // of OSLog throttling. warning→emergency is always recorded. + if memory.level != lastRecordedMemoryLevel { + lastRecordedMemoryLevel = memory.level + let eventCode: TunnelFlightEventCode = memory.level == .emergency ? .memoryCritical : .memoryWarning + diagnosticsLock.lock() + let peak = physicalFootprintPeakBytes + diagnosticsLock.unlock() + recordProviderEvent( + category: "memory", + message: "memory_pressure \(memory.description)", + flightEvent: eventCode, + value0: memory.physFootprintBytes ?? 0, + value1: memory.residentBytes ?? 0, + value2: peak + ) + } + + // OSLog throttled separately. let now = Date() var shouldLog = false stateLock.lock() @@ -2171,9 +2211,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard shouldLog else { return } logger.warning("Tunnel memory pressure \(memory.level.rawValue) \(memory.description)") #if FPTN_SIGNPOSTS - if memory.level == .warning || memory.level == .emergency { - signpostLock.lock(); TunnelSignposts.memoryWarning(); signpostLock.unlock() - } + signpostLock.lock(); TunnelSignposts.memoryWarning(); signpostLock.unlock() #endif } } diff --git a/TunnelDiagnosticsCore/include/FptnTunnelDiagnostics.h b/TunnelDiagnosticsCore/include/FptnTunnelDiagnostics.h index 441cda0..cf9da22 100644 --- a/TunnelDiagnosticsCore/include/FptnTunnelDiagnostics.h +++ b/TunnelDiagnosticsCore/include/FptnTunnelDiagnostics.h @@ -13,6 +13,23 @@ extern "C" { #endif +// ── Schema constants ───────────────────────────────────────────────── + +uint16_t fptn_diagnostics_schema_version(void); +size_t fptn_flight_recorder_capacity(void); +size_t fptn_flight_recorder_record_size(void); +size_t fptn_flight_ring_file_size(void); + +// ── Read status ────────────────────────────────────────────────────── + +typedef enum { + FPTN_READ_OK = 0, + FPTN_READ_FILE_NOT_FOUND = 1, + FPTN_READ_INVALID_HEADER = 2, + FPTN_READ_EMPTY = 3, + FPTN_READ_IO_ERROR = 4 +} FptnReadStatus; + // ── Flight Recorder ────────────────────────────────────────────────── @@ -125,7 +142,7 @@ typedef struct { } FptnDecodedLifecycleSnapshot; // Reads the latest valid snapshot from a double-buffered file. -int +FptnReadStatus fptn_lifecycle_store_read_latest( const char *path, FptnDecodedLifecycleSnapshot *output); @@ -145,11 +162,12 @@ typedef struct { uint64_t value_2; } FptnDecodedFlightEvent; -size_t +FptnReadStatus fptn_flight_recorder_read_valid( const char *path, FptnDecodedFlightEvent *output, - size_t output_capacity); + size_t output_capacity, + size_t *output_count); #ifdef __cplusplus } diff --git a/TunnelDiagnosticsCore/src/CApi.cpp b/TunnelDiagnosticsCore/src/CApi.cpp index ff2b2e6..b8d9460 100644 --- a/TunnelDiagnosticsCore/src/CApi.cpp +++ b/TunnelDiagnosticsCore/src/CApi.cpp @@ -3,9 +3,29 @@ #include #include "DiagnosticsReader.hpp" +#include "DiskFormat.hpp" #include "FlightRecorder.hpp" #include "LifecycleStore.hpp" +// ── Schema constants ───────────────────────────────────────────────── + +extern "C" uint16_t fptn_diagnostics_schema_version(void) { + return fptn::diag::kSchemaVersion; +} + +extern "C" size_t fptn_flight_recorder_capacity(void) { + return fptn::diag::kFlightRingCapacity; +} + +extern "C" size_t fptn_flight_recorder_record_size(void) { + return fptn::diag::kFlightRecordSize; +} + +extern "C" size_t fptn_flight_ring_file_size(void) { + return fptn::diag::kFlightRingHeaderSize + + fptn::diag::kFlightRingCapacity * fptn::diag::kFlightRecordSize; +} + // ── Flight Recorder ────────────────────────────────────────────────── struct FptnFlightRecorderHandle { @@ -64,20 +84,23 @@ fptn_flight_recorder_flush(void* handle) { return rec->recorder->Flush() ? 1 : 0; } -extern "C" size_t +extern "C" FptnReadStatus fptn_flight_recorder_read_valid( const char* path, FptnDecodedFlightEvent* output, - size_t output_capacity) { - if (!path || !output || output_capacity == 0) return 0; + size_t output_capacity, + size_t* output_count) { + if (!output_count) return FPTN_READ_IO_ERROR; + *output_count = 0; + if (!path || !output || output_capacity == 0) return FPTN_READ_IO_ERROR; - // 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); + size_t count = 0; + const auto status = fptn::diag::ReadValidEvents(path, events, cap, &count); for (size_t i = 0; i < count; ++i) { output[i].sequence = events[i].sequence; @@ -92,7 +115,8 @@ fptn_flight_recorder_read_valid( output[i].value_2 = events[i].value_2; } - return count; + *output_count = count; + return static_cast(status); } // ── Lifecycle Snapshot Store ───────────────────────────────────────── @@ -185,14 +209,17 @@ fptn_lifecycle_store_write( return st->store->Write(snap, synchronize != 0) ? 1 : 0; } -extern "C" int +extern "C" FptnReadStatus fptn_lifecycle_store_read_latest( const char* path, FptnDecodedLifecycleSnapshot* output) { - if (!path || !output) return false; + if (!path || !output) return FPTN_READ_IO_ERROR; fptn::diag::Snapshot snap; - if (!fptn::diag::ReadLatestSnapshot(path, snap)) return 0; + const auto status = fptn::diag::ReadLatestSnapshot(path, snap); + if (status != fptn::diag::ReadStatus::kOk) { + return static_cast(status); + } output->write_sequence = snap.write_sequence; output->pid = snap.pid; @@ -224,5 +251,5 @@ fptn_lifecycle_store_read_latest( output->outbound_queued_bytes_peak = snap.outbound_queued_bytes_peak; output->latest_event_sequence = snap.latest_event_sequence; - return 1; + return FPTN_READ_OK; } diff --git a/TunnelDiagnosticsCore/src/DiagnosticsReader.cpp b/TunnelDiagnosticsCore/src/DiagnosticsReader.cpp index 633c93b..2c86edf 100644 --- a/TunnelDiagnosticsCore/src/DiagnosticsReader.cpp +++ b/TunnelDiagnosticsCore/src/DiagnosticsReader.cpp @@ -1,29 +1,75 @@ #include "DiagnosticsReader.hpp" +#include "DiskFormat.hpp" #include "PosixFile.hpp" #include +#include +#include +#include namespace fptn::diag { -std::size_t ReadValidEvents(const char* path, Event* output, - std::size_t capacity) noexcept { +namespace { + +std::uint32_t ReadU32LE(const std::byte* p) noexcept { + return static_cast(static_cast(p[0])) | + (static_cast(static_cast(p[1])) << 8) | + (static_cast(static_cast(p[2])) << 16) | + (static_cast(static_cast(p[3])) << 24); +} + +std::uint16_t ReadU16LE(const std::byte* p) noexcept { + return static_cast( + static_cast(static_cast(p[0])) | + (static_cast(static_cast(p[1])) << 8)); +} + +} // namespace + +ReadStatus ReadValidEvents(const char* path, Event* output, + std::size_t capacity, + std::size_t* output_count) noexcept { + *output_count = 0; + const int fd = ::open(path, O_RDONLY); - if (fd < 0) return 0; + if (fd < 0) return errno == ENOENT ? ReadStatus::kFileNotFound : ReadStatus::kIoError; PosixFile file(fd); - // Validate header. + // Validate complete header. std::array header_bytes{}; if (!PreadAll(fd, header_bytes.data(), header_bytes.size(), 0)) { - return 0; + return ReadStatus::kIoError; } - 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); + const std::uint32_t magic = ReadU32LE(&header_bytes[0]); if (magic != kFlightRingMagic) { - return 0; + return ReadStatus::kInvalidHeader; + } + + const std::uint16_t schema = ReadU16LE(&header_bytes[4]); + const std::uint16_t header_size = ReadU16LE(&header_bytes[6]); + const std::uint16_t record_size = ReadU16LE(&header_bytes[8]); + const std::uint16_t ring_capacity = ReadU16LE(&header_bytes[10]); + const std::uint32_t header_crc = ReadU32LE(&header_bytes[12]); + + if (schema != kSchemaVersion || header_size != kFlightRingHeaderSize || + record_size != kFlightRecordSize || ring_capacity != kFlightRingCapacity) { + return ReadStatus::kInvalidHeader; + } + + // Validate header CRC (over bytes 0..11). + const std::uint32_t computed_crc = Crc32(header_bytes.data(), 12); + if (header_crc != computed_crc) { + return ReadStatus::kInvalidHeader; + } + + // Validate file size. + const std::size_t expected_size = + kFlightRingHeaderSize + kFlightRingCapacity * kFlightRecordSize; + struct stat st; + if (::fstat(fd, &st) != 0 || + static_cast(st.st_size) < expected_size) { + return ReadStatus::kInvalidHeader; } // Read all slots, validate, collect. @@ -44,18 +90,23 @@ std::size_t ReadValidEvents(const char* path, Event* output, } } + if (count == 0) { + return ReadStatus::kEmpty; + } + // Sort by sequence. std::sort(output, output + count, [](const Event& a, const Event& b) { return a.sequence < b.sequence; }); - return count; + *output_count = count; + return ReadStatus::kOk; } -bool ReadLatestSnapshot(const char* path, Snapshot& output) noexcept { +ReadStatus ReadLatestSnapshot(const char* path, Snapshot& output) noexcept { const int fd = ::open(path, O_RDONLY); - if (fd < 0) return false; + if (fd < 0) return errno == ENOENT ? ReadStatus::kFileNotFound : ReadStatus::kIoError; PosixFile file(fd); bool found = false; @@ -80,7 +131,7 @@ bool ReadLatestSnapshot(const char* path, Snapshot& output) noexcept { } } - return found; + return found ? ReadStatus::kOk : ReadStatus::kEmpty; } } // namespace fptn::diag diff --git a/TunnelDiagnosticsCore/src/DiagnosticsReader.hpp b/TunnelDiagnosticsCore/src/DiagnosticsReader.hpp index 60898d0..68d2fe5 100644 --- a/TunnelDiagnosticsCore/src/DiagnosticsReader.hpp +++ b/TunnelDiagnosticsCore/src/DiagnosticsReader.hpp @@ -4,14 +4,18 @@ 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; +enum class ReadStatus : int { + kOk = 0, + kFileNotFound = 1, + kInvalidHeader = 2, + kEmpty = 3, + kIoError = 4, +}; -// 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; +ReadStatus ReadValidEvents(const char* path, Event* output, + std::size_t capacity, + std::size_t* output_count) noexcept; + +ReadStatus ReadLatestSnapshot(const char* path, Snapshot& output) noexcept; } // namespace fptn::diag diff --git a/project.yml b/project.yml index e23de5a..f5f4349 100644 --- a/project.yml +++ b/project.yml @@ -40,7 +40,7 @@ configs: packages: FptnShared: url: https://github.com/mrmidi/FptnShared.git - from: "0.2.0" + exactVersion: "0.2.0" swift-log: url: https://github.com/apple/swift-log.git from: "1.6.0"