Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions FptnVPNTests/FptnVPNTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,36 @@ struct FptnVPNTests {
#expect(rssWarningWithoutFootprint.level == .warning)
}

// PR-1 (Measurement Safety): the emergency threshold (42 MiB) must not
// stop, replace, or reconnect the native WebSocket bridge. The provider's
// response at both warning and emergency is log-only — no bridge
// destruction, generation bump, reconnect scheduling, or state transition.
@Test func memoryPressureActionIsLogOnlyAtAllThresholds() {
#expect(TunnelMemoryPressureAction.action(for: .normal) == .none)
#expect(TunnelMemoryPressureAction.action(for: .warning) == .logOnly)
#expect(TunnelMemoryPressureAction.action(for: .emergency) == .logOnly)

let emergency = TunnelMemoryPressureSnapshot(
residentBytes: 50 * 1024 * 1024,
physFootprintBytes: 45 * 1024 * 1024
)
#expect(emergency.level == .emergency)
#expect(TunnelMemoryPressureAction.action(for: emergency.level) == .logOnly)

let exactlyAtEmergency = TunnelMemoryPressureSnapshot(
residentBytes: nil,
physFootprintBytes: TunnelMemoryPressureSnapshot.emergencyThresholdBytes
)
#expect(exactlyAtEmergency.level == .emergency)

let oneBelowEmergency = TunnelMemoryPressureSnapshot(
residentBytes: nil,
physFootprintBytes: TunnelMemoryPressureSnapshot.emergencyThresholdBytes - 1
)
#expect(oneBelowEmergency.level == .warning)
#expect(TunnelMemoryPressureAction.action(for: oneBelowEmergency.level) == .logOnly)
}

@Test func diagnosticsStoreReadsOldHeartbeatWithoutFootprint() throws {
let root = try temporaryDiagnosticsDirectory()
try """
Expand Down
26 changes: 24 additions & 2 deletions FptnVPNTunnel/Logging/TunnelLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,16 @@
static let shared = TunnelLogLevelStore()

private let lock = NSLock()
private var current: TunnelRuntimeLogLevel = .info
// PR-1 (Measurement Safety): Measurement builds start at .warning so
// early startup messages (before setTunnelLogLevel is called) never
// reach the formatter, redactor, or os_log at info/debug level.
private var current: TunnelRuntimeLogLevel = {
#if FPTN_MEASUREMENT_BUILD
return .warning
#else
return .info
#endif
}()

func get() -> TunnelRuntimeLogLevel {
lock.lock()
Expand All @@ -68,7 +77,14 @@

func set(_ level: TunnelRuntimeLogLevel) {
lock.lock()
// PR-1 (Measurement Safety): Measurement builds are locked to
// warning-only so info/debug messages never reach the formatter,
// redactor, or os_log during memory profiling.
#if FPTN_MEASUREMENT_BUILD
current = .warning
#else
current = level
#endif
lock.unlock()
}
}
Expand Down Expand Up @@ -96,7 +112,7 @@

/// Mirror of AppLogHandler for the tunnel extension process.
/// Writes to the same App Group log file and to os_log.
struct TunnelLogHandler: Logging.LogHandler {

Check warning on line 115 in FptnVPNTunnel/Logging/TunnelLogger.swift

View workflow job for this annotation

GitHub Actions / Xcode (FptnVPN)

deprecated default implementation is used to satisfy instance method 'log(event:)' required by protocol 'LogHandler': You should implement this method instead of using the default implementation

Check warning on line 115 in FptnVPNTunnel/Logging/TunnelLogger.swift

View workflow job for this annotation

GitHub Actions / Xcode (FptnVPN)

deprecated default implementation is used to satisfy instance method 'log(event:)' required by protocol 'LogHandler': You should implement this method instead of using the default implementation

Check warning on line 115 in FptnVPNTunnel/Logging/TunnelLogger.swift

View workflow job for this annotation

GitHub Actions / Xcode (FptnVPN)

deprecated default implementation is used to satisfy instance method 'log(event:)' required by protocol 'LogHandler': You should implement this method instead of using the default implementation

Check warning on line 115 in FptnVPNTunnel/Logging/TunnelLogger.swift

View workflow job for this annotation

GitHub Actions / Xcode (FptnVPN)

deprecated default implementation is used to satisfy instance method 'log(event:)' required by protocol 'LogHandler': You should implement this method instead of using the default implementation

let label: String
var metadata: Logging.Logger.Metadata = [:]
Expand Down Expand Up @@ -141,8 +157,14 @@
}
os_log("%{public}@", log: osLog, type: osType, text)

// 2. Ring buffer → background flush to shared file
// PR-1 (Measurement Safety): the 1 MiB in-memory ring buffer and its
// 512 KiB file sink are compiled out of Release builds. Release
// builds do not retain provider text in the application-owned ring
// or shared log file; sparse messages continue through os_log.
// Debug/internal builds retain the ring for developer diagnostics.
#if DEBUG
RingLogSink.tunnel.write(text)
#endif
}

private func format(
Expand Down
103 changes: 49 additions & 54 deletions FptnVPNTunnel/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,6 @@
private var lastStopReasonRawValue: Int?
private var counters = PacketCounters()
private var lastMemoryWarningAt: Date?
private var memoryEmergencyReconnectInProgress = false
private var readBackpressureUntil: Date?
private var readBackpressureWorkItem: DispatchWorkItem?
private var consecutiveSendFailureBatches = 0
Expand Down Expand Up @@ -267,7 +266,6 @@
lastNetworkPathSummary = "unknown"
counters = PacketCounters()
lastMemoryWarningAt = nil
memoryEmergencyReconnectInProgress = false
readBackpressureUntil = nil
readBackpressureWorkItem = nil
consecutiveSendFailureBatches = 0
Expand Down Expand Up @@ -540,7 +538,6 @@
self.isReadLoopActive = true
self.reconnectAttempt = 0
self.lastTransportError = nil
self.memoryEmergencyReconnectInProgress = false
self.stateLock.unlock()

self.updateRuntimeState(.connected, reason: "websocket connected and settings applied")
Expand All @@ -557,7 +554,6 @@
stateLock.lock()
reconnectAttempt = 0
lastTransportError = nil
memoryEmergencyReconnectInProgress = false
stateLock.unlock()

updateRuntimeState(.connected, reason: "transport recovered")
Expand Down Expand Up @@ -937,7 +933,7 @@

let protocolNumber = ipProtocolNumber(for: packet)
packetFlow.writePackets([packet], withProtocols: [protocolNumber])
recordPacketFlowWrite(byteCount: Int64(packet.count))

Check warning on line 936 in FptnVPNTunnel/PacketTunnelProvider.swift

View workflow job for this annotation

GitHub Actions / Xcode (FptnVPN)

result of call to 'recordPacketFlowWrite(byteCount:)' is unused

Check warning on line 936 in FptnVPNTunnel/PacketTunnelProvider.swift

View workflow job for this annotation

GitHub Actions / Xcode (FptnVPN)

result of call to 'recordPacketFlowWrite(byteCount:)' is unused
}

private func shouldContinueReadLoop() -> Bool {
Expand Down Expand Up @@ -1392,13 +1388,25 @@
}

private func emitTelemetry() {
#if FPTN_MEASUREMENT_BUILD
// PR-1 (Measurement Safety): sample only Mach memory counters.
// Avoids constructing the full TunnelRuntimeSnapshot (native
// status query, ISO-8601 dates, string copies, packet counters)
// on every 15-second tick during memory profiling.
let memory = TunnelMemoryPressureSnapshot(
residentBytes: residentMemoryBytes(),
physFootprintBytes: physFootprintBytes()
)
evaluateMemoryPressure(memory: memory)
#else
let snapshot = currentSnapshot()
let memory = TunnelMemoryPressureSnapshot(
residentBytes: snapshot.memoryResidentBytes,
physFootprintBytes: snapshot.memoryPhysFootprintBytes
)
updateDiagnosticsHeartbeat(snapshot: snapshot, lastEvent: "telemetry")
evaluateMemoryPressure(snapshot: snapshot, memory: memory)
evaluateMemoryPressure(memory: memory)
#endif
}

// MARK: - State helpers
Expand Down Expand Up @@ -1486,29 +1494,49 @@
)
}

// PR-1 (Measurement Safety): @autoclosure prevents the interpolated
// message string from being constructed in Measurement builds.
// The #if gate eliminates the entire call (including category string
// literals) at compile time rather than relying on a runtime guard
// inside TunnelDiagnosticsStore.
private func recordProviderEvent(
category: String,
message: String,
message: @autoclosure () -> String,
runtimeState: String? = nil,
generation: Int? = nil,
reconnectAttempt: Int? = nil,
pathSatisfied: Bool? = nil
) {
#if FPTN_MEASUREMENT_BUILD
return
#else
TunnelDiagnosticsStore.shared.recordProviderEvent(
category: category,
message: message,
message: message(),
runtimeState: runtimeState,
generation: generation,
reconnectAttempt: reconnectAttempt,
pathSatisfied: pathSatisfied
)
#endif
}

private func updateDiagnosticsHeartbeat(lastEvent: String) {
updateDiagnosticsHeartbeat(snapshot: currentSnapshot(), lastEvent: lastEvent)
// PR-1 (Measurement Safety): @autoclosure prevents interpolated
// lastEvent strings (e.g. "state=\(state.rawValue)") from being
// constructed in Measurement builds.
private func updateDiagnosticsHeartbeat(lastEvent: @autoclosure () -> String) {
#if FPTN_MEASUREMENT_BUILD
return
#else
updateDiagnosticsHeartbeat(snapshot: currentSnapshot(), lastEvent: lastEvent())
#endif
}

private func updateDiagnosticsHeartbeat(snapshot: TunnelRuntimeSnapshot, lastEvent: String) {
private func updateDiagnosticsHeartbeat(snapshot: TunnelRuntimeSnapshot, lastEvent: @autoclosure () -> String) {
#if FPTN_MEASUREMENT_BUILD
return
#endif
let event = lastEvent()
let generation: Int
let pathSatisfied: Bool
stateLock.lock()
Expand All @@ -1529,7 +1557,7 @@
websocketRunning: snapshot.websocketRunning,
lastTransportError: snapshot.lastTransportError ?? snapshot.websocketLastError,
lastStopReason: snapshot.lastStopReason,
lastEvent: lastEvent,
lastEvent: event,
lastInboundActivityAt: snapshot.lastInboundActivityAt,
lastOutboundActivityAt: snapshot.lastOutboundActivityAt,
packetFlowReadPackets: snapshot.packetFlowReadPackets,
Expand Down Expand Up @@ -1580,14 +1608,21 @@
return UInt64(info.phys_footprint)
}

// PR-1 (Measurement Safety): memory pressure is now telemetry-only.
// The previous implementation destroyed the native WebSocket bridge and
// scheduled a full reconnect at the emergency threshold (42 MiB).
// Recreating TLS, WebSocket, queues and buffers near the memory ceiling
// increased peak usage and obscured the original source of memory growth.
// Both warning and emergency levels now emit a throttled OSLog entry only.
// No bridge stop, bridge creation, reconnect scheduling, JSONL event
// recording, or heartbeat rewrite originates from this method.
private func evaluateMemoryPressure(
snapshot: TunnelRuntimeSnapshot,
memory: TunnelMemoryPressureSnapshot
) {
switch memory.level {
case .normal:
return
case .warning:
case .warning, .emergency:
let now = Date()
var shouldLog = false
stateLock.lock()
Expand All @@ -1598,47 +1633,7 @@
}
stateLock.unlock()
guard shouldLog else { return }
logger.warning("Tunnel memory pressure warning \(memory.description)")
recordProviderEvent(category: "memory", message: "memory_pressure_warning \(memory.description)", runtimeState: snapshot.runtimeState.rawValue)
updateDiagnosticsHeartbeat(snapshot: snapshot, lastEvent: "memory_pressure_warning")
case .emergency:
triggerMemoryPressureReconnect(snapshot: snapshot, memory: memory)
}
}

private func triggerMemoryPressureReconnect(
snapshot: TunnelRuntimeSnapshot,
memory: TunnelMemoryPressureSnapshot
) {
stateLock.lock()
guard !memoryEmergencyReconnectInProgress,
!shutdownRequested,
runtimeState == .connected || runtimeState == .reasserting || runtimeState == .waitingForNetwork else {
stateLock.unlock()
return
}
memoryEmergencyReconnectInProgress = true
websocketGeneration += 1
lastTransportError = "provider_memory_pressure"
stateLock.unlock()

logger.warning("Tunnel memory pressure emergency, reconnecting websocket \(memory.description)")
recordProviderEvent(
category: "memory",
message: "memory_pressure_emergency reconnect \(memory.description)",
runtimeState: snapshot.runtimeState.rawValue
)
updateDiagnosticsHeartbeat(snapshot: snapshot, lastEvent: "memory_pressure_emergency")
cancelPendingReconnect()
replaceWebSocketClient(with: nil, stopCurrent: true)

switch scheduleReconnectIfPossible() {
case .scheduled:
updateRuntimeState(.reasserting, reason: "provider_memory_pressure")
case .waitingForNetwork:
updateRuntimeState(.waitingForNetwork, reason: "provider_memory_pressure")
case .unavailable:
failRuntimeTunnel(reason: "provider_memory_pressure")
logger.warning("Tunnel memory pressure \(memory.level.rawValue) \(memory.description)")
}
}

Expand Down
31 changes: 31 additions & 0 deletions SharedTunnelRuntime/TunnelDiagnosticsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ enum TunnelMemoryPressureLevel: String, Codable, Sendable {
case emergency
}

// PR-1 (Measurement Safety): the provider's response to memory pressure.
// Both warning and emergency are log-only — no bridge stop, replacement,
// or reconnect is ever triggered by memory thresholds.
enum TunnelMemoryPressureAction: Equatable, Sendable {
case none
case logOnly

static func action(for level: TunnelMemoryPressureLevel) -> TunnelMemoryPressureAction {
switch level {
case .normal:
return .none
case .warning, .emergency:
return .logOnly
}
}
}

struct TunnelMemoryPressureSnapshot: Equatable, Sendable {
static let warningThresholdBytes: UInt64 = 30 * 1024 * 1024
static let emergencyThresholdBytes: UInt64 = 42 * 1024 * 1024
Expand Down Expand Up @@ -141,6 +158,18 @@ struct TunnelProviderFailureReport: Equatable, Sendable {
final class TunnelDiagnosticsStore: @unchecked Sendable {
static let shared = TunnelDiagnosticsStore()

// PR-1 (Measurement Safety): compile-time gate. In Measurement builds,
// recordProviderEvent and writeHeartbeat are no-ops. This is a computed
// property (not mutable state) so no cross-thread race is possible and
// no call can accidentally write diagnostics before startTunnel runs.
static var providerRecordingEnabled: Bool {
#if FPTN_MEASUREMENT_BUILD
return false
#else
return true
#endif
}

private static let appGroup = "group.net.mrmidi.FptnVPN"
private let queue = DispatchQueue(label: "org.fptn.diagnostics-store", qos: .utility)
private let encoder = JSONEncoder()
Expand Down Expand Up @@ -170,6 +199,7 @@ final class TunnelDiagnosticsStore: @unchecked Sendable {
reconnectAttempt: Int? = nil,
pathSatisfied: Bool? = nil
) {
guard Self.providerRecordingEnabled else { return }
let event = TunnelDiagnosticEvent(
timestamp: Self.now(),
source: "provider",
Expand All @@ -184,6 +214,7 @@ final class TunnelDiagnosticsStore: @unchecked Sendable {
}

func writeHeartbeat(_ heartbeat: TunnelProviderHeartbeat) {
guard Self.providerRecordingEnabled else { return }
writeJSON(heartbeat, to: .providerHeartbeat)
}

Expand Down
8 changes: 8 additions & 0 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ settings:
configs:
Debug: debug
Release: release
Measurement: release

packages:
FptnShared:
Expand Down Expand Up @@ -172,6 +173,13 @@ 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
configs:
# PR-1 (Measurement Safety): the Measurement configuration disables
# legacy JSONL diagnostics, forces warning-only logging, and uses a
# memory-only telemetry path. Build with -configuration Measurement
# to produce a clean baseline for native memory profiling.
Measurement:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: $(inherited) FPTN_MEASUREMENT_BUILD
info:
path: FptnVPNTunnel/Info.plist
entitlements:
Expand Down
Loading