diff --git a/Columba.xcodeproj/project.pbxproj b/Columba.xcodeproj/project.pbxproj index 3e9f6e9b..31a0ec74 100644 --- a/Columba.xcodeproj/project.pbxproj +++ b/Columba.xcodeproj/project.pbxproj @@ -153,7 +153,6 @@ 7CE3523F6D1459A0A5CE76BE /* RNodeSeamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0AA6C472B1D4EFA2896086 /* RNodeSeamTests.swift */; }; 83F513B2A83B444F977E44D9 /* ReticulumSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A1380C075A9F67E1B173EA94 /* ReticulumSwift */; }; 85C01EE558921012E4E057D6 /* NodeHostContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6FA51D689C6EEA75831D2C8 /* NodeHostContractTests.swift */; }; - B309C475AAFC405051A82829 /* TunnelTeardownTimeoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8080510781074E76A587DA31 /* TunnelTeardownTimeoutTests.swift */; }; 8768F2E6CD7941D82997A1BB /* CallControlButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71922EC204982A357F814F23 /* CallControlButton.swift */; }; 886AB689C7699471510BAF9A /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86E428836698BA8A8973A92F /* CallManager.swift */; }; 89AF655B749BC8073AE370D7 /* ProxyIPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = PXIF /* ProxyIPC.swift */; }; @@ -190,6 +189,7 @@ APVT002 /* MessageAttachmentPreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = APVT001 /* MessageAttachmentPreviewTests.swift */; }; B028EFF3B3954B91F4C2A5FB /* BLESeamDriverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBDT /* BLESeamDriverTests.swift */; }; B1F0B685186B32F160D7D446 /* ExtensionFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F078 /* ExtensionFrameReader.swift */; }; + B309C475AAFC405051A82829 /* TunnelTeardownTimeoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8080510781074E76A587DA31 /* TunnelTeardownTimeoutTests.swift */; }; B94D42232270A2B0DCF6C3F0 /* NodeInboundReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = MB02 /* NodeInboundReplay.swift */; }; BB88CE246AEE328C3D7DAC99 /* AutoAnnouncePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C625A513CF7628DC1512541 /* AutoAnnouncePolicyTests.swift */; }; BGPSYNCBUILD001 /* BackgroundPropagationSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = BGPSYNCREF001 /* BackgroundPropagationSync.swift */; }; @@ -238,6 +238,7 @@ EDF2F6B945FAA23366617FD6 /* TCPClientWizardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD87870C760723D7E77C87E9 /* TCPClientWizardViewModel.swift */; }; EDL1B /* ExtensionDiagLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDLF /* ExtensionDiagLog.swift */; }; EDL2B /* ExtensionDiagLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDLF /* ExtensionDiagLog.swift */; }; + EDLR002 /* ExtensionDiagLogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDLR001 /* ExtensionDiagLogRotationTests.swift */; }; EF7151DBA88C8FA1EC49AE5A /* PythonNodeBLEBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC4 /* PythonNodeBLEBridge.swift */; }; F3BB96DDC79D81151A2794F9 /* DiscoveredInterfacesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6CAE98185D8B2D41D2ACA7E /* DiscoveredInterfacesScreen.swift */; }; F4CA7E2F745F05E21D45E11A /* RNSAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 0FD6A68A52A54D21FDB70324 /* RNSAPI */; }; @@ -258,6 +259,11 @@ N0C4 /* PythonNodeBLEBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC4 /* PythonNodeBLEBridge.swift */; }; NDDI_NE /* NERNodeDiagLogShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = NDDI /* NERNodeDiagLogShim.swift */; }; NMF2B /* NomadNetFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = NMFF /* NomadNetFetch.swift */; }; + NMH1B /* NEMemoryHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = NMHF /* NEMemoryHistory.swift */; }; + NMT1B /* NEMemoryTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = NMTF /* NEMemoryTrace.swift */; }; + NMT2B /* NEMemoryTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = NMTF /* NEMemoryTrace.swift */; }; + NMTTB /* NEMemoryTraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NMTTSF /* NEMemoryTraceTests.swift */; }; + NMV1B /* NEMemoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NMVF /* NEMemoryView.swift */; }; NRRB_NE /* PythonRNodeBLEBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E59C40C0DF095105ECF040B /* PythonRNodeBLEBridge.swift */; }; OBQ2B /* OutboxQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBQF /* OutboxQueue.swift */; }; OGFWR02 /* OggOpusFileWriterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OGFWR01 /* OggOpusFileWriterTests.swift */; }; @@ -268,7 +274,6 @@ PXI2B /* ProxyIPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = PXIF /* ProxyIPC.swift */; }; RFT002 /* RuntimeFlavorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RFT001 /* RuntimeFlavorTests.swift */; }; RMLT002 /* RuntimeActivityMonitorLeaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RMLT001 /* RuntimeActivityMonitorLeaseTests.swift */; }; - EDLR002 /* ExtensionDiagLogRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDLR001 /* ExtensionDiagLogRotationTests.swift */; }; RQP02 /* ReplyQuoteParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = RQP01 /* ReplyQuoteParityTests.swift */; }; SIDSBN /* SentIdStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = SIDSF /* SentIdStore.swift */; }; SPLASHBLD1 /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = SPLASHPROD1 /* Splash */; }; @@ -360,6 +365,7 @@ 71922EC204982A357F814F23 /* CallControlButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/Views/Call/CallControlButton.swift; sourceTree = SOURCE_ROOT; }; 7529BF99835005DE07E1B65F /* CodecSelectionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/Views/Call/CodecSelectionSheet.swift; sourceTree = SOURCE_ROOT; }; 7CE6E7F6812429F902DB95E7 /* NodePythonBundleLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NodePythonBundleLayout.swift; path = Sources/RNSHost/NodePythonBundleLayout.swift; sourceTree = SOURCE_ROOT; }; + 8080510781074E76A587DA31 /* TunnelTeardownTimeoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TunnelTeardownTimeoutTests.swift; path = Tests/ColumbaAppTests/TunnelTeardownTimeoutTests.swift; sourceTree = SOURCE_ROOT; }; 853F1E1C2B6E5305277FCB2A /* MigrationRoundTripTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/MigrationRoundTripTests.swift; sourceTree = SOURCE_ROOT; }; 86E428836698BA8A8973A92F /* CallManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/Services/CallManager.swift; sourceTree = SOURCE_ROOT; }; 894C730FEB9AE4CDB4B949F8 /* BackgroundDeliveryPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/Views/Onboarding/BackgroundDeliveryPage.swift; sourceTree = SOURCE_ROOT; }; @@ -423,6 +429,7 @@ EA0AA6C472B1D4EFA2896086 /* RNodeSeamTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/RNodeSeamTests.swift; sourceTree = SOURCE_ROOT; }; EBA4F8C4C9008A06DFF5AC8A /* PythonConfigWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/Services/PythonConfigWriter.swift; sourceTree = SOURCE_ROOT; }; EDLF /* ExtensionDiagLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Shared/ExtensionDiagLog.swift; sourceTree = SOURCE_ROOT; }; + EDLR001 /* ExtensionDiagLogRotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/ExtensionDiagLogRotationTests.swift; sourceTree = SOURCE_ROOT; }; EPROD /* ColumbaNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ColumbaNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F001 /* ColumbaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/App/ColumbaApp.swift; sourceTree = SOURCE_ROOT; }; F002 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/Theme/Theme.swift; sourceTree = SOURCE_ROOT; }; @@ -525,7 +532,6 @@ F0A5 /* ColumbaModelBApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ColumbaModelBApp.entitlements; sourceTree = ""; }; F6CAE98185D8B2D41D2ACA7E /* DiscoveredInterfacesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/Views/Settings/DiscoveredInterfacesScreen.swift; sourceTree = SOURCE_ROOT; }; F6FA51D689C6EEA75831D2C8 /* NodeHostContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NodeHostContractTests.swift; path = Tests/ColumbaAppTests/NodeHostContractTests.swift; sourceTree = SOURCE_ROOT; }; - 8080510781074E76A587DA31 /* TunnelTeardownTimeoutTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TunnelTeardownTimeoutTests.swift; path = Tests/ColumbaAppTests/TunnelTeardownTimeoutTests.swift; sourceTree = SOURCE_ROOT; }; FBDS /* BLEDriverSeam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Shared/BLEDriverSeam.swift; sourceTree = SOURCE_ROOT; }; FBDT /* BLESeamDriverTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/BLESeamDriverTests.swift; sourceTree = SOURCE_ROOT; }; FBST /* AppGroupBLESeamTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Shared/AppGroupBLESeamTransport.swift; sourceTree = SOURCE_ROOT; }; @@ -550,6 +556,10 @@ MSTST001 /* MessageStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MessageStoreTests.swift; path = Tests/ColumbaAppTests/MessageStoreTests.swift; sourceTree = SOURCE_ROOT; }; NDDI /* NERNodeDiagLogShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaNetworkExtension/NERNodeDiagLogShim.swift; sourceTree = SOURCE_ROOT; }; NMFF /* NomadNetFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Shared/NomadNetFetch.swift; sourceTree = SOURCE_ROOT; }; + NMHF /* NEMemoryHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/Services/NEMemoryHistory.swift; sourceTree = SOURCE_ROOT; }; + NMTF /* NEMemoryTrace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Shared/NEMemoryTrace.swift; sourceTree = SOURCE_ROOT; }; + NMTTSF /* NEMemoryTraceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/NEMemoryTraceTests.swift; sourceTree = SOURCE_ROOT; }; + NMVF /* NEMemoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/ColumbaApp/Views/Settings/NEMemoryView.swift; sourceTree = SOURCE_ROOT; }; OBQF /* OutboxQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Shared/OutboxQueue.swift; sourceTree = SOURCE_ROOT; }; OGFWR01 /* OggOpusFileWriterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/OggOpusFileWriterTests.swift; sourceTree = SOURCE_ROOT; }; OGGR001 /* OggOpusGranuleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/OggOpusGranuleTests.swift; sourceTree = SOURCE_ROOT; }; @@ -564,7 +574,6 @@ RDRF01 /* ProxyDrainRaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/ProxyDrainRaceTests.swift; sourceTree = SOURCE_ROOT; }; RFT001 /* RuntimeFlavorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/RuntimeFlavorTests.swift; sourceTree = SOURCE_ROOT; }; RMLT001 /* RuntimeActivityMonitorLeaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/RuntimeActivityMonitorLeaseTests.swift; sourceTree = SOURCE_ROOT; }; - EDLR001 /* ExtensionDiagLogRotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/ExtensionDiagLogRotationTests.swift; sourceTree = SOURCE_ROOT; }; RQP01 /* ReplyQuoteParityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/ReplyQuoteParityTests.swift; sourceTree = SOURCE_ROOT; }; SIDSF /* SentIdStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/Shared/SentIdStore.swift; sourceTree = SOURCE_ROOT; }; TMP001 /* ThemeManagerPersistenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Tests/ColumbaAppTests/ThemeManagerPersistenceTests.swift; sourceTree = SOURCE_ROOT; }; @@ -1023,6 +1032,9 @@ F074 /* SharedDefaults.swift */, F076 /* SharedFrameQueue.swift */, EDLF /* ExtensionDiagLog.swift */, + NMTF /* NEMemoryTrace.swift */, + NMHF /* NEMemoryHistory.swift */, + NMVF /* NEMemoryView.swift */, AGPF /* AppGroupPaths.swift */, F079 /* OnboardingRestoreSheet.swift */, F07A /* PlatformCompat.swift */, @@ -1097,6 +1109,7 @@ B2E4D1020000000000000002 /* NetworkStatusEndpointTests.swift */, 498D7BF18FE3ACDE874B3E98 /* PeerLocationFormattingTests.swift */, CHF001 /* CallHistoryFormattingTests.swift */, + NMTTSF /* NEMemoryTraceTests.swift */, CHRT01 /* CallHistoryRepositoryTests.swift */, BGPSYNCTESTREF001 /* BackgroundPropagationSyncTests.swift */, FT03 /* MicronParserTests.swift */, @@ -1378,6 +1391,7 @@ NRRB_NE /* PythonRNodeBLEBridge.swift in Sources */, NDDI_NE /* NERNodeDiagLogShim.swift in Sources */, EDL2B /* ExtensionDiagLog.swift in Sources */, + NMT2B /* NEMemoryTrace.swift in Sources */, AGP2B /* AppGroupPaths.swift in Sources */, AGB2B /* AppGroupBridgeInterface.swift in Sources */, PXI2B /* ProxyIPC.swift in Sources */, @@ -1516,6 +1530,9 @@ 074B /* SharedDefaults.swift in Sources */, 076B /* SharedFrameQueue.swift in Sources */, EDL1B /* ExtensionDiagLog.swift in Sources */, + NMT1B /* NEMemoryTrace.swift in Sources */, + NMH1B /* NEMemoryHistory.swift in Sources */, + NMV1B /* NEMemoryView.swift in Sources */, AGP1B /* AppGroupPaths.swift in Sources */, 079B /* OnboardingRestoreSheet.swift in Sources */, 07AB /* PlatformCompat.swift in Sources */, @@ -1595,6 +1612,7 @@ A1F3C0010000000000000001 /* NetworkStatusEndpointTests.swift in Sources */, CC067EE77A33341C829CBDE8 /* PeerLocationFormattingTests.swift in Sources */, CHF002 /* CallHistoryFormattingTests.swift in Sources */, + NMTTB /* NEMemoryTraceTests.swift in Sources */, CHRT02 /* CallHistoryRepositoryTests.swift in Sources */, BGPSYNCTESTBUILD001 /* BackgroundPropagationSyncTests.swift in Sources */, T003 /* MicronParserTests.swift in Sources */, diff --git a/Sources/ColumbaApp/App/ColumbaApp.swift b/Sources/ColumbaApp/App/ColumbaApp.swift index ab11f2d9..e1cb1877 100644 --- a/Sources/ColumbaApp/App/ColumbaApp.swift +++ b/Sources/ColumbaApp/App/ColumbaApp.swift @@ -546,6 +546,9 @@ final class ColumbaApplicationRuntime { // The NE (sandboxed) writes ext-diag.log to the shared container; the host // copies the previous background session's log out here on each launch. DiagLog.copyExtensionDiagToDocuments() + // Same treatment for the NE's memory ring: snapshot it into Documents + // once per launch so the post-kill footprint history is pullable. + DiagLog.copyMemoryRingToDocuments() #if DEBUG // Keep that copy LIVE (not just this launch's snapshot) so on-device NE // diagnostics can be tailed in real time. DEBUG-only. diff --git a/Sources/ColumbaApp/Services/AppServices.swift b/Sources/ColumbaApp/Services/AppServices.swift index 185c2aa1..5bb9bd51 100644 --- a/Sources/ColumbaApp/Services/AppServices.swift +++ b/Sources/ColumbaApp/Services/AppServices.swift @@ -227,6 +227,9 @@ enum DiagLog { /// is unavailable. NO-PII: the source carries envelope/metadata only — see /// `ExtensionDiagLog`'s contract. static func copyExtensionDiagToDocuments() { + // Recover logs written by a build that stored them at the App-Group + // root (unpullable) before this one moved the writer under Library/. + ExtensionDiagLog.migrateLegacyRootLog() guard let source = ExtensionDiagLog.fileURL, FileManager.default.fileExists(atPath: source.path) else { return @@ -237,6 +240,32 @@ enum DiagLog { try? FileManager.default.copyItem(at: source, to: dest) } + /// Copy the Network Extension's memory ring (`NEMemoryTrace`'s + /// `Library/ne-memory.bin`) into the app's Documents directory so the + /// post-kill footprint history is retrievable via `devicectl ... copy from + /// --domain-type appDataContainer` even if the App-Group copy gets rotated + /// or the container isn't directly reachable. Once per launch, NOT on the + /// live-copy loop: the ring's mtime advances on every NE write, so a + /// periodic mirror would re-copy 512 KB every cycle and double the + /// disk-write profile of a device that is already budget-bound. The + /// mtime guard skips the copy when the snapshot is already current. + /// NO-PII: the ring carries footprint numbers, pids, and wallMs only. + static func copyMemoryRingToDocuments() { + guard let source = NEMemoryTrace.fileURL, + FileManager.default.fileExists(atPath: source.path) else { + return + } + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let dest = docs.appendingPathComponent("ne-memory.bin") + if let srcMtime = try? FileManager.default.attributesOfItem(atPath: source.path)[.modificationDate] as? Date, + let dstMtime = try? FileManager.default.attributesOfItem(atPath: dest.path)[.modificationDate] as? Date, + srcMtime <= dstMtime { + return + } + try? FileManager.default.removeItem(at: dest) + try? FileManager.default.copyItem(at: source, to: dest) + } + #if DEBUG /// Keep `Documents/ext-diag.log` LIVE (refresh ~every 2s) instead of a single /// launch-time snapshot, so on-device NE diagnostics — including the smoke diff --git a/Sources/ColumbaApp/Services/NEMemoryHistory.swift b/Sources/ColumbaApp/Services/NEMemoryHistory.swift new file mode 100644 index 00000000..0f42cd73 --- /dev/null +++ b/Sources/ColumbaApp/Services/NEMemoryHistory.swift @@ -0,0 +1,355 @@ +// +// NEMemoryHistory.swift +// ColumbaApp +// +// Turns the Network Extension's self-reported memory ring (`NEMemoryTrace`, +// written by the NE process into the App-Group container) into the series and +// the kill attribution the settings chart renders. +// +// ── WHY THIS LIVES IN THE APP ─────────────────────────────────────────────── +// The NE cannot read its own future: when iOS jetsams it, the process is +// SIGKILLed with no callback. Everything knowable about a kill is therefore +// what the dead process managed to write down, plus what is ABSENT from it. +// This file is where that absence is interpreted. +// +// ── HOW A KILL IS DETECTED ────────────────────────────────────────────────── +// A process generation is a run of records sharing one pid. It ends either with +// a `.stop` record (the NE saw `stopTunnel`, so the teardown was clean and +// `reason` says who asked) or with nothing at all. "Nothing at all" is the kill +// signature: there is no `NEProviderStopReason.memoryLow` to look for, and a +// memory kill delivers no callback, so absence is the only available evidence. +// The newest generation is ambiguous — no `.stop` means either "still running" +// or "killed and not yet restarted" — which is why `tunnelConnected` is an +// input rather than a guess. +// + +import Foundation +import NetworkExtension + +/// Analysis of the NE memory trace: chart series, generation boundaries, and the +/// memory figure to report for a process iOS killed. +enum NEMemoryHistory { + + // MARK: - Output types + + /// One plotted sample. `segment` breaks the line wherever continuity is not + /// honest: across a process generation boundary and across a device sleep. + struct Point: Identifiable, Equatable { + let id: Int + let date: Date + /// Footprint at the end of the sample window (MB). + let footprintMB: Double + /// Highest footprint inside the window (MB) — the spike envelope. + let windowMaxMB: Double + let segment: Int + let pid: UInt32 + } + + enum EventKind: Equatable { + case boot + /// Clean teardown; carries the `NEProviderStopReason` raw value. + case cleanStop(reason: Int) + /// The generation ended with no `stopTunnel` marker: killed. + case kill + } + + struct Event: Identifiable, Equatable { + let id: Int + let date: Date + let kind: EventKind + /// Last footprint the dead generation wrote (MB), when known. + let lastSeenMB: Double? + /// Peak footprint during that generation's lifetime (MB), when known. + let peakMB: Double? + /// Headroom to the limit at the last sample (MB); nil when the OS + /// reported no limit for this process. + let headroomMB: Double? + } + + /// The newest generation, while it is still up. + struct Current: Equatable { + let footprintMB: Double + let windowMaxMB: Double + /// Peak since that process launched (MB). + let peakMB: Double + /// Headroom to the current limit (MB); nil = unknown. + let headroomMB: Double? + let startedAt: Date + let pid: UInt32 + } + + struct Summary: Equatable { + /// False when the trace file does not exist yet — i.e. no NE process has + /// run since the feature shipped, which is NOT the same as "0 MB". + let hasTrace: Bool + let points: [Point] + let events: [Event] + let current: Current? + let peak24hMB: Double? + /// NE process generations started in the last 24 h (a kill shows up here). + let restarts24h: Int + let lastKill: Event? + + init( + hasTrace: Bool = false, points: [Point] = [], events: [Event] = [], + current: Current? = nil, peak24hMB: Double? = nil, + restarts24h: Int = 0, lastKill: Event? = nil + ) { + self.hasTrace = hasTrace + self.points = points + self.events = events + self.current = current + self.peak24hMB = peak24hMB + self.restarts24h = restarts24h + self.lastKill = lastKill + } + } + + // MARK: - Stop reasons + + /// Human-readable name for the `NEProviderStopReason` raw value a `.stop` + /// marker carries. Uses the real enum rather than duplicating the integers, + /// so the names cannot drift from the SDK; unknown codes (a newer SDK + /// writing an older app's file) fall through to a raw-value string. + /// Values from the iPhoneOS 27.1 SDK `NEProvider.h`. + static func stopReasonName(_ raw: Int) -> String { + // `rawValue:` returns an optional; bind it first so `case .none` below + // matches the ENUM's none case and not Optional.none. + guard let reason = NEProviderStopReason(rawValue: raw) else { + return "unknown reason code \(raw)" + } + switch reason { + case .none: return "no specific reason" + case .userInitiated: return "stopped by the user" + case .providerFailed: return "provider failed" + case .noNetworkAvailable: return "no network available" + case .unrecoverableNetworkChange: return "unrecoverable network change" + case .providerDisabled: return "provider disabled" + case .authenticationCanceled: return "authentication cancelled" + case .configurationFailed: return "configuration failed" + case .idleTimeout: return "idle timeout" + case .configurationDisabled: return "configuration disabled" + case .configurationRemoved: return "configuration removed" + case .superceded: return "superceded by a higher-priority configuration" + case .userLogout: return "user logout" + case .userSwitch: return "user switch" + case .connectionFailed: return "connection failed" + case .sleep: return "device went to sleep" + case .appUpdate: return "provider app updating" + case .internalError: return "internal error" + @unknown default: return "unknown reason code \(raw)" + } + } + + // MARK: - Thresholds + + /// Wall-clock gap beyond which the series is broken rather than bridged. + /// Buckets land at worst every 60 s, so a 3-minute gap means the writer was + /// not running (device asleep, or the process gone). + static let gapThresholdSeconds: TimeInterval = 180 + + /// How far wall time may run ahead of the uptime clock before the line is + /// broken as a device sleep. Sleep pauses `mach_absolute_time` (the source + /// of `uptimeMs`) but not `wallMs`, so a 60 s nap between buckets looks like + /// a 60 s wall gap with ~1 s of uptime — far below `gapThresholdSeconds`, + /// yet nothing was recorded. Normal operation keeps the two clocks in step + /// (both advance by the sample interval), so a 30 s skew is decisively + /// abnormal and cannot be produced by scheduler jitter. + static let sleepSkewSeconds: TimeInterval = 30 + + static let daySeconds: TimeInterval = 86_400 + + // MARK: - Analysis + + /// Whether the tunnel process is believed alive, gone, or mid-transition. + /// Kill attribution needs a stable verdict: `.connecting`/`.connected` mean + /// the NE is recording right now (a kill must not be inferred from the + /// missing stop marker), `.disconnected`/`.invalid` mean it is gone, and + /// `.disconnecting`/`.reasserting` leave the newest generation undecided — + /// it is neither a corpse nor a live reading yet. + enum TunnelState: Equatable { + case alive, gone, undecided + + init(_ status: NEVPNStatus) { + switch status { + case .connected, .connecting: self = .alive + case .disconnected, .invalid: self = .gone + case .disconnecting, .reasserting: self = .undecided + @unknown default: self = .undecided + } + } + } + + /// - Parameter tunnelState: whether the VPN/NE is alive right now. Decides + /// the one ambiguous case — a newest generation with no stop marker. + static func analyze( + _ trace: NEMemoryTrace.Trace, + tunnelState: TunnelState, + now: Date = Date() + ) -> Summary { + let merged = mergedRecords(trace) + guard !merged.isEmpty else { return Summary(hasTrace: trace.live != nil || !trace.records.isEmpty) } + + let generations = groupByGeneration(merged) + var events: [Event] = [] + var points: [Point] = [] + var segment = 0 + var previousDate: Date? + var previousUptimeMs: Int64? + var previousPid: UInt32? + var eventId = 0 + var pointId = 0 + + for (index, generation) in generations.enumerated() { + let last = generation[generation.count - 1] + let endedCleanly = last.kind == .stop + let isLatestGeneration = index == generations.count - 1 + // No stop marker + (a newer generation exists, or the tunnel is + // believed gone now) ⇒ the process was killed. A newer generation is + // itself proof: iOS only relaunches the NE after it tore the previous + // one down. A mid-transition state leaves the newest generation + // undecided — `.connecting` means it is recording right now, and + // showing "stopped unexpectedly" for a live process is the exact + // false kill this state exists to prevent. + let killed = !endedCleanly && (!isLatestGeneration || tunnelState == .gone) + + if let first = generation.first, first.kind == .boot { + events.append(Event( + id: eventId, date: date(fromMs: first.wallMs), kind: .boot, + lastSeenMB: nil, peakMB: nil, headroomMB: nil + )) + eventId += 1 + } + + if killed { + // The best number for "what was it using when it got killed": + // the LAST footprint it wrote (`last` — the maximum would pair a + // stale high reading with this event's date and headroom) and + // the peak it reached — the latter from + // `ledger_phys_footprint_peak`, which catches spikes that fell + // between samples and so is never under-reported. + let lastSeen = last.footprint + let peak = max( + generation.map { $0.lifetimePeak }.max() ?? 0, + generation.map { $0.windowMax }.max() ?? 0 + ) + events.append(Event( + id: eventId, date: date(fromMs: last.wallMs), kind: .kill, + lastSeenMB: megabytes(lastSeen), + peakMB: peak > 0 ? megabytes(peak) : nil, + headroomMB: last.limitRemaining > 0 ? megabytes(last.limitRemaining) : nil + )) + eventId += 1 + } else if endedCleanly { + events.append(Event( + id: eventId, date: date(fromMs: last.wallMs), + kind: .cleanStop(reason: Int(last.reason)), + lastSeenMB: megabytes(last.footprint), + peakMB: last.lifetimePeak > 0 ? megabytes(last.lifetimePeak) : nil, + headroomMB: last.limitRemaining > 0 ? megabytes(last.limitRemaining) : nil + )) + eventId += 1 + } + + for record in generation { + let date = date(fromMs: record.wallMs) + if let previous = previousDate { + let wallGap = date.timeIntervalSince(previous) + // Sleep detection: wall time ran ahead of the uptime clock + // (which sleep pauses) by more than jitter could explain. + let uptimeGap = previousUptimeMs.map { + TimeInterval(record.uptimeMs - $0) / 1000 + } + let slept = uptimeGap.map { wallGap - $0 > sleepSkewSeconds } ?? false + if wallGap > gapThresholdSeconds || slept { + segment += 1 + } + } + if let previous = previousPid, previous != record.pid { + segment += 1 + } + previousDate = date + previousUptimeMs = record.uptimeMs + previousPid = record.pid + // Markers are events, not samples: a marker's `windowMax` holds + // the process-LIFETIME peak, so plotting it would draw a spike at + // every boot and teardown instead of a window maximum. + guard record.kind == .bucket || record.kind == .live else { continue } + points.append(Point( + id: pointId, date: date, + footprintMB: megabytes(record.footprint), + windowMaxMB: megabytes(record.windowMax), + segment: segment, pid: record.pid + )) + pointId += 1 + } + } + + // Newest generation is "current" only while the tunnel process is + // actually alive; otherwise the newest records are a corpse, not a + // reading — and a mid-transition state has not decided yet. + var current: Current? + if tunnelState == .alive, let generation = generations.last { + let newest = generation[generation.count - 1] + let first = generation[0] + current = Current( + footprintMB: megabytes(newest.footprint), + windowMaxMB: megabytes(max(newest.windowMax, newest.footprint)), + peakMB: megabytes(max(newest.lifetimePeak, generation.map { $0.windowMax }.max() ?? 0)), + headroomMB: newest.limitRemaining > 0 ? megabytes(newest.limitRemaining) : nil, + startedAt: date(fromMs: first.wallMs), + pid: newest.pid + ) + } + + let dayAgo = now.addingTimeInterval(-daySeconds) + let recent = points.filter { $0.date >= dayAgo } + let peak24h = recent.map { max($0.footprintMB, $0.windowMaxMB) }.max() + let restarts = events.filter { $0.kind == .boot && $0.date >= dayAgo }.count + let lastKill = events.last { $0.kind == .kill } + + return Summary( + hasTrace: true, points: points, events: events, current: current, + peak24hMB: peak24h, restarts24h: restarts, lastKill: lastKill + ) + } + + // MARK: - Helpers + + /// Ring records plus the live slot, when the live slot is newer than the last + /// bucket of the same generation. After a clean stop the live slot is stale, + /// and appending it would draw a dead process as still sampling. + static func mergedRecords(_ trace: NEMemoryTrace.Trace) -> [NEMemoryTrace.Record] { + var all = trace.records + guard let live = trace.live else { return all } + if let last = all.last, live.pid == last.pid, live.uptimeMs > last.uptimeMs { + all.append(live) + } else if all.isEmpty { + all.append(live) + } + return all.filter { $0.wallMs > 0 } + } + + /// Consecutive runs sharing one pid. A pid change is a new process, whether + /// or not a boot marker landed — a kill during startup can lose the marker. + static func groupByGeneration(_ records: [NEMemoryTrace.Record]) -> [[NEMemoryTrace.Record]] { + var generations: [[NEMemoryTrace.Record]] = [] + for record in records { + if var last = generations.last, last.last?.pid == record.pid { + last.append(record) + generations[generations.count - 1] = last + } else { + generations.append([record]) + } + } + return generations + } + + static func megabytes(_ bytes: UInt64) -> Double { + Double(bytes) / 1_048_576 + } + + static func date(fromMs ms: Int64) -> Date { + Date(timeIntervalSince1970: Double(ms) / 1000) + } +} diff --git a/Sources/ColumbaApp/Views/MainTabView.swift b/Sources/ColumbaApp/Views/MainTabView.swift index 78afe47b..49358663 100644 --- a/Sources/ColumbaApp/Views/MainTabView.swift +++ b/Sources/ColumbaApp/Views/MainTabView.swift @@ -96,6 +96,14 @@ struct MainTabView: View { /// Session-scoped route request raised by the disconnected-interface banner. /// Settings consumes it after its navigation stack and interface model exist. @State private var shouldOpenInterfaceManagement: Bool = false + #if DEBUG + /// DEBUG-only capture affordance: the `ui-open-memory-usage` launch argument + /// presents the Memory Usage screen directly, so it can be screenshotted on a + /// physical device without UI taps. Same convention as the `ui-screenshotter` + /// argument handled in `ColumbaApp`; unreachable in Release builds. + @State private var debugMemoryPresented = + ProcessInfo.processInfo.arguments.contains("ui-open-memory-usage") + #endif /// Session-scoped route request raised by the Map tab's peer contact /// sheet ("Message" button): the peer's destination hash. MainTabView /// switches to Chats and hands the hash to ChatsView, which resolves the @@ -197,6 +205,16 @@ struct MainTabView: View { } } .tint(Theme.accentColor) + #if DEBUG + .fullScreenCover(isPresented: $debugMemoryPresented) { + NavigationStack { + NEMemoryView( + tunnel: appServices.tunnelManager, + hostingMode: appServices.hostingMode + ) + } + } + #endif .onAppear { consumePendingRNodeSetup() routePendingDeepLink() diff --git a/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift b/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift index 3957958a..56cf09da 100644 --- a/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift +++ b/Sources/ColumbaApp/Views/Settings/BackgroundTransportView.swift @@ -10,6 +10,7 @@ struct BackgroundTransportView: View { let onDismiss: () -> Void @State private var selectedHost = NodeHostingMode.selected @State private var showDetail = false + @State private var showMemory = false var body: some View { NavigationStack { @@ -59,6 +60,24 @@ struct BackgroundTransportView: View { .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) } .buttonStyle(.plain) + + Button { + showMemory = true + } label: { + HStack(spacing: 8) { + Image(systemName: "chart.xyaxis.line") + .font(.system(size: 14, weight: .medium)) + Text(String(localized: "Memory Usage")) + .font(.system(size: 15, weight: .medium)) + } + .foregroundStyle(Theme.textPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Theme.backgroundTertiary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("ne_memory_entry") } .padding(20) } @@ -74,6 +93,12 @@ struct BackgroundTransportView: View { .navigationDestination(isPresented: $showDetail) { BackgroundTransportDetail() } + .navigationDestination(isPresented: $showMemory) { + NEMemoryView( + tunnel: tunnel, + hostingMode: activeHost + ) + } } } } diff --git a/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift b/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift new file mode 100644 index 00000000..f3dcb019 --- /dev/null +++ b/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift @@ -0,0 +1,336 @@ +// +// NEMemoryView.swift +// ColumbaApp +// +// Memory usage of the background transport process, charted from the trace the +// Network Extension writes about itself. +// +// Reachable from Settings → Reticulum Hosting. The numbers are the NE's own +// `phys_footprint` samples — the same metric iOS enforces its kill ceiling on — +// so a value here is directly comparable to "how close was it to being killed". +// The app cannot measure the NE from the outside (separate sandboxed process), +// which is why the series stops whenever the NE is not running, and why a kill +// is inferred from a missing clean-stop marker rather than reported by the OS. +// + +import SwiftUI +import Charts + +@available(iOS 17.0, macOS 14.0, *) +struct NEMemoryView: View { + /// The tunnel manager, observed live: the kill card and the "now" reading + /// must follow the tunnel's actual state while the page is open, not the + /// state at construction time (a page opened during `.connecting` must not + /// show "stopped unexpectedly" for a process that is recording right now). + /// `nil` (no tunnel configured) behaves like a gone tunnel. + let tunnel: TunnelManager? + /// Foreground-only hosting means there is no separate process to measure. + let hostingMode: NodeHostingMode + + @State private var summary = NEMemoryHistory.Summary() + @State private var range: MemoryRange = .day + /// The app's OWN footprint — a different process from the one charted, and + /// the only half of the picture iOS lets the app measure directly. + @State private var appFootprintMB: Double? = nil + + enum MemoryRange: String, CaseIterable, Identifiable { + case hour = "1 h" + case day = "24 h" + case week = "7 d" + + var id: String { rawValue } + + var seconds: TimeInterval { + switch self { + case .hour: return 3_600 + case .day: return 86_400 + case .week: return 604_800 + } + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if hostingMode == .foreground { + foregroundModeNotice + } + killCard + statusCard + statsRow + rangePicker + chartCard + explainer + } + .padding(20) + } + .background(Theme.backgroundPrimary) + .foregroundStyle(Theme.textPrimary) + .navigationTitle(String(localized: "Memory Usage")) + .navigationBarTitleDisplayMode(.inline) + .task { await reloadLoop() } + .refreshable { await reload() } + .onChange(of: tunnel?.status) { _, _ in Task { await reload() } } + } + + // MARK: - Sections + + private var foregroundModeNotice: some View { + Label { + Text("Foreground-only hosting is active, so there is no separate background process to measure. Switch to background hosting to record this data.") + } icon: { + Image(systemName: "exclamationmark.triangle") + } + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Theme.backgroundTertiary) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + + /// The headline answer to "how much was it using when iOS killed it". + @ViewBuilder private var killCard: some View { + if let kill = summary.lastKill { + VStack(alignment: .leading, spacing: 8) { + Label { + Text("Stopped unexpectedly") + .font(.headline) + } icon: { + Image(systemName: "exclamationmark.octagon.fill") + } + .foregroundStyle(Color.red) + + Text("Last recorded \(formatMB(kill.lastSeenMB)), peak \(formatMB(kill.peakMB)) — \(kill.date, format: .relative(presentation: .named))") + .font(.subheadline) + .accessibilityIdentifier("ne_memory_killed_card") + + if let headroom = kill.headroomMB { + Text("\(formatMB(headroom)) of headroom was left at the last sample.") + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + } + + Text("Columba infers this from the trace ending with no clean stop. iOS does not report memory kills to apps, and a memory kill delivers no callback at all.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.red.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: Theme.cornerRadiusMedium)) + } + } + + private var statusCard: some View { + VStack(alignment: .leading, spacing: 6) { + if let current = summary.current { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(formatMB(current.footprintMB)) + .font(.system(size: 30, weight: .semibold, design: .rounded)) + .accessibilityIdentifier("ne_memory_current") + Text("now").font(.subheadline).foregroundStyle(Theme.textSecondary) + Spacer() + if let headroom = current.headroomMB { + Text("\(formatMB(headroom)) left") + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + } + } + Text("Running for \(current.startedAt, format: .relative(presentation: .named))") + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + } else if summary.hasTrace { + Text("Not running").font(.title3.weight(.semibold)) + Text("The background transport is stopped, so nothing is being recorded right now. The history below is what it wrote before it stopped.") + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + if let last = summary.events.last, case let .cleanStop(reason) = last.kind { + Text("Teardown reason: \(NEMemoryHistory.stopReasonName(reason)) (\(reason))") + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + } + } else { + Text("No data yet").font(.title3.weight(.semibold)) + Text("Enable background hosting and leave it running for a minute; the first samples appear here.") + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .glassCard() + } + + private var statsRow: some View { + HStack(spacing: 12) { + stat(title: "Peak (24 h)", value: formatMB(summary.peak24hMB)) + stat(title: "Restarts (24 h)", value: "\(summary.restarts24h)") + stat(title: "Samples", value: "\(summary.points.count)") + } + } + + private func stat(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(value).font(.system(.body, design: .rounded).weight(.semibold)) + Text(title).font(.caption2).foregroundStyle(Theme.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .glassCard() + } + + private var rangePicker: some View { + Picker(String(localized: "Range"), selection: $range) { + ForEach(MemoryRange.allCases) { option in + Text(option.rawValue).tag(option) + } + } + .pickerStyle(.segmented) + .onChange(of: range) { _, _ in + Task { await reload() } + } + } + + @ViewBuilder private var chartCard: some View { + if visiblePoints.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("No samples in this range").font(.subheadline) + Text("Try a wider range, or let the background transport run longer.") + .font(.footnote).foregroundStyle(Theme.textSecondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .glassCard() + } else { + VStack(alignment: .leading, spacing: 8) { + Chart { + ForEach(visiblePoints) { point in + if point.windowMaxMB > point.footprintMB { + // The spike envelope: what the process peaked at INSIDE + // the sample window. Jetsam acts on the peak, not on the + // value the window happened to end at, so a chart of the + // closing value alone would hide the thing that kills it. + AreaMark( + x: .value("Time", point.date), + yStart: .value("Footprint", point.footprintMB), + yEnd: .value("Window max", point.windowMaxMB), + series: .value("Run", "\(point.segment)") + ) + .foregroundStyle(Theme.accentColor.opacity(0.22)) + .interpolationMethod(.linear) + } + LineMark( + x: .value("Time", point.date), + y: .value("Memory", point.footprintMB), + series: .value("Run", "\(point.segment)") + ) + .foregroundStyle(Theme.accentColor) + .interpolationMethod(.linear) + } + if let limit = limitLineMB { + RuleMark(y: .value("iOS limit", limit)) + .foregroundStyle(Color.orange) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + .annotation(position: .top, alignment: .leading) { + Text("iOS limit") + .font(.caption2) + .foregroundStyle(Color.orange) + } + } + ForEach(killEvents) { event in + RuleMark(x: .value("Stopped", event.date)) + .foregroundStyle(Color.red.opacity(0.75)) + .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [3, 2])) + } + } + .chartYAxisLabel("MB", position: .leading) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.hour().day()) + } + } + .frame(height: 220) + .accessibilityIdentifier("ne_memory_chart") + + Text("The line is the footprint at the end of each sample window; the shaded band is the highest it reached inside that window. Gaps mean nothing was recorded — the device was asleep or the process was gone. Lines are never bridged across them.") + .font(.caption2) + .foregroundStyle(Theme.textSecondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .glassCard() + } + } + + private var explainer: some View { + VStack(alignment: .leading, spacing: 6) { + Text("About this data").font(.headline) + Text("Memory is the process footprint iOS charges against its per-process limit, sampled once a second by the background transport itself and kept in a rolling window of a few days. It includes the Reticulum runtime that runs inside that process.") + .font(.footnote) + .foregroundStyle(Theme.textSecondary) + Text("Recent iOS builds enforce a ceiling of roughly 50 MB on this class of process; the exact number is only visible in the kernel log, so the limit line appears only when iOS also reports a remaining budget, which it does not always do here.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + if let appFootprintMB { + Text("Columba's own interface process is using \(formatMB(appFootprintMB)) right now. The chart is the background transport process only: iOS keeps the two apart, and an app cannot read another process's memory.") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + } + } + } + + // MARK: - Derived data + + private var visiblePoints: [NEMemoryHistory.Point] { + let cutoff = Date().addingTimeInterval(-range.seconds) + return summary.points.filter { $0.date >= cutoff } + } + + private var killEvents: [NEMemoryHistory.Event] { + let cutoff = Date().addingTimeInterval(-range.seconds) + return summary.events.filter { $0.kind == .kill && $0.date >= cutoff } + } + + /// Footprint + reported headroom = the ceiling iOS is enforcing right now. + /// Derived rather than hardcoded: the per-process limit varies by device and + /// iOS version, and is only knowable when the OS reports remaining budget. + private var limitLineMB: Double? { + guard let current = summary.current, let headroom = current.headroomMB else { return nil } + let limit = current.footprintMB + headroom + // A zero/near-zero headroom reading means iOS reported no usable limit. + return limit > current.footprintMB + 1 ? limit : nil + } + + private func formatMB(_ value: Double?) -> String { + guard let value else { return "—" } + return String(format: "%.1f MB", value) + } + + // MARK: - Loading + + /// Ring read off the main actor: it is a 512 KB file read, cheap, but it must + /// not run inside the view's body on the main thread. + private func reload() async { + let state = tunnel.map { NEMemoryHistory.TunnelState($0.status) } ?? .gone + // Sampling our own footprint is one syscall; do it on the main actor + // rather than paying an hop for it. + appFootprintMB = Double(MemoryFootprint.sample().footprint) / 1_048_576 + let trace = await Task.detached(priority: .utility) { + NEMemoryTrace.readAll() + }.value + summary = NEMemoryHistory.analyze(trace, tunnelState: state) + } + + /// Keep the "now" reading and the kill attribution current while the page + /// is visible: the ring grows every second and the tunnel can start, die, + /// or restart at any moment. `.refreshable` and `.onChange` cover the + /// explicit triggers; this covers everything else. + private func reloadLoop() async { + while !Task.isCancelled { + await reload() + try? await Task.sleep(for: .seconds(5)) + } + } +} diff --git a/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift b/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift index d8fe5f15..cdf459d2 100644 --- a/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift +++ b/Sources/ColumbaNetworkExtension/PacketTunnelProvider.swift @@ -61,6 +61,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider { userInfo: [NSLocalizedDescriptionKey: "Foreground-only mode is selected"])) return } + // Self-reported memory trajectory. The app cannot measure this process + // from outside its own sandbox, and a jetsam kill delivers no callback, + // so the NE is the only thing that can record what it was using. The + // sampler runs on its own native thread: the main dispatch thread is + // blocked during bring-up and announce floods. + NEMemoryTrace.start() // SINGLE RUNTIME: Python RNS is the only Reticulum runtime in the NE. // Initialize CPython here (non-blocking - it must never gate tunnel @@ -130,6 +136,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { ExtensionDiagLog.log("stopTunnel reason=\(reason.rawValue)") + // Writing this marker is what lets the app tell a clean teardown from a + // kill. A memory kill never reaches here, so its absence is the signal. + NEMemoryTrace.recordStop(reason: reason.rawValue) // Track C3: tear down the in-NE node. Stopping the node drops its TCP // relay interface + AppGroupBridge. Fire-and-forget — teardown is diff --git a/Sources/Shared/ExtensionDiagLog.swift b/Sources/Shared/ExtensionDiagLog.swift index 4c48020f..789f0a4b 100644 --- a/Sources/Shared/ExtensionDiagLog.swift +++ b/Sources/Shared/ExtensionDiagLog.swift @@ -3,7 +3,8 @@ // Columba Shared // // Append-only diagnostic logger for the Network Extension, backed by a file in -// the App-Group container (`ext-diag.log`). The NE is sandboxed and its +// the App-Group container's `Library/` directory (`Library/ext-diag.log`). The +// NE is sandboxed and its // unified-log output does not reliably reach the host over WiFi-only devices, // so the NE writes its diagnostics here; the main app copies this file into its // Documents directory on launch (`copyExtensionDiagToDocuments()`), making it @@ -44,9 +45,38 @@ public enum ExtensionDiagLog { ) else { return nil } - return containerURL.appendingPathComponent("ext-diag.log") + // Under `Library/`, not the container root: `devicectl device copy from` + // only exposes Library / Documents / tmp inside a container, so a log at + // the App-Group root cannot be pulled off a device for post-kill analysis + // (remoteservices error 11007). Same constraint `NEMemoryTrace` hit. + return containerURL + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("ext-diag.log") }() + /// One-time migration: `ext-diag.log` used to live at the App-Group ROOT, + /// which `devicectl` cannot pull (only Library / Documents / tmp are + /// exposed inside a container). Move the legacy root file and its rotated + /// sibling into `Library/` when the destinations are absent, so a device + /// upgraded across the move keeps its previous session's diagnostics + /// pullable. Best-effort: any failure leaves the old files in place. + /// Call from the APP (it reads the log via the launch-time mirror); the NE + /// must not race its own writer with a move. + public static func migrateLegacyRootLog() { + guard let newURL = fileURL else { return } + let library = newURL.deletingLastPathComponent() + let containerRoot = library.deletingLastPathComponent() + for name in ["ext-diag.log", "ext-diag.log.1"] { + let source = containerRoot.appendingPathComponent(name) + let destination = library.appendingPathComponent(name) + let fm = FileManager.default + guard !fm.fileExists(atPath: destination.path), + fm.fileExists(atPath: source.path) else { continue } + try? fm.createDirectory(at: library, withIntermediateDirectories: true) + try? fm.moveItem(at: source, to: destination) + } + } + /// Maximum size of a single `ext-diag.log` file before rotation (5 MB), /// mirroring the app-side `DiagLog` cap. The NE runs for days without a /// process restart; without a bound the file grew to 427 MB on a device @@ -121,6 +151,11 @@ public enum ExtensionDiagLog { fh.closeFile() } } else { + // `Library/` is not guaranteed to exist inside the App-Group container. + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) // `createFile` lets us stamp file protection atomically with creation // so there's no window where the file exists at default protection. FileManager.default.createFile( diff --git a/Sources/Shared/NEMemoryTrace.swift b/Sources/Shared/NEMemoryTrace.swift new file mode 100644 index 00000000..14cd0cfb --- /dev/null +++ b/Sources/Shared/NEMemoryTrace.swift @@ -0,0 +1,712 @@ +// +// NEMemoryTrace.swift +// Columba Shared +// +// Self-reported memory trajectory for the Network Extension, written to a +// fixed-size ring in the App-Group container so the host app can chart it. +// +// ── WHY THIS EXISTS ───────────────────────────────────────────────────────── +// The NE is a separate sandboxed process. The app CANNOT read another +// process's memory (`proc_pid_rusage` on a foreign pid is EPERM under the +// sandbox, and libproc.h is not even shipped in the iOS SDK), and iOS exposes +// no programmatic jetsam report. So the only way to know what the NE was using +// when iOS killed it is for the NE to write the number down while it is still +// alive. A SIGKILL does NOT lose buffered file writes — only power loss does — +// so the last record the dead process wrote is readable by the next launch. +// +// ── METRIC ────────────────────────────────────────────────────────────────── +// `task_vm_info.phys_footprint` is the ONLY number comparable to the kernel's +// kill ceiling (`memorystatus: … exceeded mem limit: ActiveHard MB`). +// `resident_size` / `ru_maxrss` also count shared file-backed pages and are +// always higher — comparing them to that ceiling is a category error. +// `os_proc_available_memory()` is deliberately NOT used: it is +// `API_UNAVAILABLE(macos)` and returns 0 for any process that "isn't an app", +// which an app extension may well be. `task_vm_info.limit_bytes_remaining` is +// the same quantity, is available on every platform this app builds for, and is +// recorded as 0 = unknown. +// +// ── SAMPLER THREAD ────────────────────────────────────────────────────────── +// A detached native `Thread`, NOT an async `Task` or a run-loop `Timer`: the +// NE's main dispatch thread is blocked during startup / announce floods, so a +// cooperative sampler silently never ticks and reports a healthy-looking empty +// trace. A sampler that never ran reads exactly like a process that never grew. +// +// ── I/O BUDGET ────────────────────────────────────────────────────────────── +// Writes are batched on purpose. A per-sample synchronous persist previously +// ACCELERATED the very kill it was measuring (an ~8 s kill cycle fell to ~1 s). +// So: sample 1 Hz, rewrite the 64-byte live slot at most every 5 s (or on a +// >= 1 MB jump), and append a ring bucket every 60 s (or on a >= 1 MB jump). +// The ring is fixed-size and never rotates: bounded disk, bounded memory, and +// the pre-kill value survives because the jump trigger fires during a ramp. +// +// This file imports ONLY Foundation and is compiled into BOTH the ColumbaApp +// and ColumbaNetworkExtension targets (like `ExtensionDiagLog` / `AppGroupPaths`). +// It MUST stay free of ReticulumSwift / RNSAPI / LXMFSwift so it compiles in the +// NE target, which links none of those. +// +// NO-PII CONTRACT: records carry byte counts, timestamps and a pid only. +// + +import Foundation + +/// Fixed-layout memory trajectory: a header, one live record rewritten in place, +/// and a ring of bucket records. Written by the NE, read by the app. +public enum NEMemoryTrace { + + // MARK: - Layout constants + + /// File magic — a mismatch means "written by an incompatible build", so the + /// reader returns empty instead of misreading stale bytes. + static let magic: UInt64 = 0x544D4243 // "CBMT" + static let layoutVersion: UInt64 = 1 + + /// Byte size of one record / of the header. Fixed, so slot N always lives at + /// the same offset and a killed writer can never shift the addressing. + static let recordSize = 64 + static let headerSize = 64 + + /// Ring capacity in records. 8192 × 64 B = 512 KB sparse file; at the 1/min + /// idle flush cadence that is ~5.6 days of history. + static let defaultRingCapacity = 8192 + + #if DEBUG + /// Test seam: shrink the ring so wrap-around is exercisable without writing + /// thousands of records. Must be set BEFORE the file is created, since the + /// capacity is stamped into the header. + static var ringCapacityOverride: Int? + #endif + + static var ringCapacity: Int { + #if DEBUG + if let override = ringCapacityOverride { return override } + #endif + return defaultRingCapacity + } + + /// Record field offsets (little-endian). + static let offKind = 0 // u8 (0 = empty slot) + static let offReason = 4 // u32 + static let offWallMs = 8 // i64 + static let offUptimeMs = 16 // i64 + static let offFootprint = 24 // u64 + static let offWindowMax = 32 // u64 + static let offLifetimePeak = 40 // u64 + static let offLimitRemaining = 48 // u64 + static let offPid = 56 // u32 + static let offChecksum = 60 // u32 (FNV-1a over bytes 0..<60) + + /// Header field offsets. + static let offMagic = 0 // u32 + static let offVersion = 4 // u16 + static let offRecordSize = 6 // u16 + static let offCapacity = 8 // u32 + static let offWriteIndex = 12 // u32 + static let offRecordCount = 16 // u32 + static let offBootCount = 20 // u32 + + /// Offset of the live slot (rewritten in place) and of the ring. + static let liveSlotOffset = UInt64(headerSize) + static let ringStartOffset = UInt64(headerSize + recordSize) + + // MARK: - Sampling cadence + + /// Sampler period. 1 Hz is one Mach call per second — negligible next to the + /// NE's own work, and fine-grained enough to see a reassembly ramp. + static let sampleIntervalSeconds: Double = 1.0 + /// Rewrite the live slot at most this often (seconds), or on a jump. + static let liveWriteIntervalSeconds: Double = 5.0 + /// Append a ring bucket at most this often (seconds), or on a jump. + static let bucketFlushIntervalSeconds: Double = 60.0 + /// A footprint rise of this size forces an immediate live write + bucket + /// flush, so a kill during a ramp leaves a fresh record behind. + static let jumpTriggerBytes: UInt64 = 1_048_576 + + // MARK: - Record kinds + + public enum Kind: UInt8, Sendable { + /// Ring slot: a bucket covering the window since the previous bucket. + case bucket = 1 + /// Ring slot: `startTunnel` — a process generation begins. + case boot = 2 + /// Ring slot: `stopTunnel` — a CLEAN teardown; `reason` carries the + /// `NEProviderStopReason` raw value. The ABSENCE of this record at the + /// end of a generation is what identifies a kill. + case stop = 3 + /// The live slot: the newest sample, rewritten in place. + case live = 4 + } + + // MARK: - Types + + /// One decoded record. Field meaning depends on `kind`; see the ring writer. + public struct Record: Equatable, Sendable { + public let kind: Kind + /// `NEProviderStopReason` raw value for `.stop`, else 0. + public let reason: UInt32 + /// Wall clock — for display. + public let wallMs: Int64 + /// `CLOCK_UPTIME_RAW` ms — does NOT advance while the device sleeps, so a + /// wall-clock gap with no uptime gap means the device was asleep and the + /// chart must draw a gap, not a flat line. + public let uptimeMs: Int64 + /// `.bucket`: last sample in the window. `.live`: newest sample. + /// `.boot`/`.stop`: footprint at that instant. + public let footprint: UInt64 + /// Max footprint observed since the previous bucket was written. + public let windowMax: UInt64 + /// `ledger_phys_footprint_peak` — never misses a spike between samples. + public let lifetimePeak: UInt64 + /// `limit_bytes_remaining`; 0 means unknown (see file header). + public let limitRemaining: UInt64 + /// Process generation. A pid change with no `.stop` record between is the + /// kill tell, and it survives even a kill before the boot marker landed. + public let pid: UInt32 + + public init( + kind: Kind, reason: UInt32, wallMs: Int64, uptimeMs: Int64, + footprint: UInt64, windowMax: UInt64, lifetimePeak: UInt64, + limitRemaining: UInt64, pid: UInt32 + ) { + self.kind = kind + self.reason = reason + self.wallMs = wallMs + self.uptimeMs = uptimeMs + self.footprint = footprint + self.windowMax = windowMax + self.lifetimePeak = lifetimePeak + self.limitRemaining = limitRemaining + self.pid = pid + } + } + + /// Everything readable out of the trace file. + public struct Trace: Sendable { + /// Ring records in chronological order, oldest first. + public var records: [Record] = [] + /// The live slot, if present. Deliberately NOT merged into `records`: the + /// caller decides whether it is newer than the last bucket of the newest + /// generation (it is not, after a clean stop). + public var live: Record? + + public init() {} + } + + /// Writer state, so the reader can order the ring. + struct Header: Equatable, Sendable { + var writeIndex: UInt32 = 0 + var recordCount: UInt32 = 0 + var bootCount: UInt32 = 0 + } + + // MARK: - File location + + /// App-Group file both processes reach. `nil` when the group is unavailable + /// (e.g. a test runner without the App-Group entitlement). + public static var fileURL: URL? { + #if DEBUG + if let override = fileURLOverride { return override } + #endif + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) else { + return nil + } + // Under `Library/`, not the container root: `devicectl device copy from` + // only exposes Library / Documents / tmp inside a container, so a file at + // the App-Group root cannot be pulled off a device - and pulling it off the + // device is the whole point of keeping it. Verified on iPhone 14 / iOS + // 27.0.1, where the root path fails with remoteservices error 11007. + return container + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("ne-memory.bin") + } + + #if DEBUG + /// Test seam: point the trace at a throwaway file. + static var fileURLOverride: URL? + #endif + + // MARK: - Sampler state + + private static let lock = NSLock() + private static var samplerRunning = false + /// Bumped on every `start()`. A thread only keeps running while its OWN + /// generation is current: without this, a `stopTunnel` immediately followed + /// by a `startTunnel` (a tunnel reconnect inside one process) would flip the + /// shared flag back to true before the old thread checked it, so the old + /// thread would never exit and the ring would gain a second permanent writer + /// — double-counted records and doubled I/O for the rest of the process life. + private static var samplerGeneration = 0 + + /// Start the sampler. Idempotent: a tunnel restart inside the same process + /// must not spawn a second writer into the same ring. + /// + /// Runs on a DETACHED NATIVE THREAD — see the file header for why a `Task` + /// or `Timer` is the wrong tool in the NE. + public static func start() { + lock.lock() + defer { lock.unlock() } + guard !samplerRunning else { return } + samplerRunning = true + samplerGeneration += 1 + let generation = samplerGeneration + + // Record the generation BEFORE the first sample so a kill during startup + // still leaves a boot marker for the next launch to attribute against. + // But first preserve the PREVIOUS generation's freshest reading: the + // live slot is written every 5 s while the ring only every 60 s, and + // once a new pid boots the reader ignores a live record whose pid no + // longer matches the newest generation. Without this, a kill+relaunch + // loses up to 60 s of the dead process's final memory when the new + // sampler overwrites the slot below. + flushForeignLiveLocked() + appendMarkerLocked(kind: .boot, reason: 0) + + let thread = Thread { runSamplerLoop(generation: generation) } + thread.name = "columba.memory-trace" + // Background QoS: never compete with delivery work, and still run when + // the main dispatch thread is wedged. + thread.qualityOfService = .background + thread.stackSize = 64 * 1024 + thread.start() + } + + /// Ask the sampler to stop; it exits within one sample period. The caller + /// writes the clean-stop marker via `recordStop(reason:)`. + public static func stopSampling() { + lock.lock() + samplerRunning = false + lock.unlock() + } + + /// Append a `.stop` marker carrying the `NEProviderStopReason` raw value. + /// Its presence is the ONLY way the app can tell a clean teardown from a + /// jetsam kill: there is no `NEProviderStopReason.memoryLow`, and a memory + /// kill delivers no callback at all. + public static func recordStop(reason: Int) { + stopSampling() + lock.lock() + defer { lock.unlock() } + appendMarkerLocked(kind: .stop, reason: UInt32(truncatingIfNeeded: reason)) + } + + // MARK: - Sampler loop + + private static func runSamplerLoop(generation: Int) { + var header = readHeader() + var windowMax: UInt64 = 0 + var lastLiveUptimeMs: Int64 = 0 + var lastFlushUptimeMs = Clocks.now().uptimeMs + var lastFlushedFootprint: UInt64 = MemoryFootprint.sample().footprint + + while isRunning(generation: generation) { + Thread.sleep(forTimeInterval: sampleIntervalSeconds) + guard isRunning(generation: generation) else { return } + + let snap = MemoryFootprint.sample() + let now = Clocks.now() + let pid = UInt32(getpid()) + // A jump is measured against what the last bucket recorded, so a + // sustained climb keeps flushing instead of tripping once. + let jumped = snap.footprint >= lastFlushedFootprint &+ jumpTriggerBytes + windowMax = max(windowMax, snap.footprint) + + lock.lock() + guard samplerRunning, samplerGeneration == generation else { lock.unlock(); return } + + if shouldWriteLive(sinceLiveMs: now.uptimeMs &- lastLiveUptimeMs, jumped: jumped) { + writeLive(Record( + kind: .live, reason: 0, + wallMs: now.wallMs, uptimeMs: now.uptimeMs, + footprint: snap.footprint, windowMax: windowMax, + lifetimePeak: snap.lifetimePeak, + limitRemaining: snap.limitRemaining, pid: pid + )) + lastLiveUptimeMs = now.uptimeMs + } + + if shouldFlushBucket(sinceFlushMs: now.uptimeMs &- lastFlushUptimeMs, jumped: jumped) { + append(Record( + kind: .bucket, reason: 0, + wallMs: now.wallMs, uptimeMs: now.uptimeMs, + footprint: snap.footprint, windowMax: windowMax, + lifetimePeak: snap.lifetimePeak, + limitRemaining: snap.limitRemaining, pid: pid + ), header: &header) + writeHeader(header) + lastFlushUptimeMs = now.uptimeMs + lastFlushedFootprint = snap.footprint + windowMax = 0 + } + lock.unlock() + } + } + + // MARK: - Flush policy (pure — unit-tested) + + /// The live slot is the record that answers "what was it using when it got + /// killed", so a jump always rewrites it regardless of the interval. + static func shouldWriteLive(sinceLiveMs: Int64, jumped: Bool) -> Bool { + jumped || sinceLiveMs >= Int64(liveWriteIntervalSeconds * 1000) + } + + static func shouldFlushBucket(sinceFlushMs: Int64, jumped: Bool) -> Bool { + jumped || sinceFlushMs >= Int64(bucketFlushIntervalSeconds * 1000) + } + + // MARK: - Markers + + /// Locked accessor for the loop condition. `NSLock` is not recursive, so the + /// thread never reads `samplerRunning` bare. + private static func isRunning(generation: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + return samplerRunning && samplerGeneration == generation + } + + /// Preserve a dead generation's last known state before a new boot. The + /// live slot is the freshest record (written every 5 s; ring buckets flush + /// every 60 s) and the reader only merges it while its pid matches the + /// newest generation, so a relaunched process must copy its predecessor's + /// live record into the ring or that reading is lost when the slot is + /// overwritten. Same-pid live slots (in-process tunnel restart) keep + /// merging, so only foreign pids are flushed. Skipped when the ring's last + /// record is already at least as new: the generation stopped cleanly (its + /// `.stop` marker postdates the last live write, and appending the live + /// record after the marker would read as a kill), or a previous boot + /// already flushed this record. Caller must hold `lock`. + private static func flushForeignLiveLocked() { + guard let url = fileURL, + let data = try? Data(contentsOf: url), + data.count >= Int(liveSlotOffset) + recordSize, + let live = decodeRecord(slice(data, Int(liveSlotOffset), recordSize)), + live.pid != UInt32(getpid()), + lastRingRecordLocked(data).map({ live.wallMs > $0.wallMs }) ?? true else { return } + var header = readHeader() + append(live, header: &header) + writeHeader(header) + } + + /// The newest decodable ring record, or nil when the ring is empty or its + /// tail is torn (a kill during the write — treat as unknown, flush). + private static func lastRingRecordLocked(_ data: Data) -> Record? { + guard let header = decodeHeader(data) else { return nil } + let filled = min(Int(header.recordCount), ringCapacity) + guard filled > 0 else { return nil } + let start = header.recordCount >= UInt32(ringCapacity) ? Int(header.writeIndex) : 0 + let slot = (start + filled - 1) % ringCapacity + return decodeRecord(slice(data, Int(ringStartOffset) + slot * recordSize, recordSize)) + } + + private static func appendMarkerLocked(kind: Kind, reason: UInt32) { + let snap = MemoryFootprint.sample() + let now = Clocks.now() + var header = readHeader() + if kind == .boot { header.bootCount &+= 1 } + append(Record( + kind: kind, reason: reason, + wallMs: now.wallMs, uptimeMs: now.uptimeMs, + footprint: snap.footprint, windowMax: snap.lifetimePeak, + lifetimePeak: snap.lifetimePeak, + limitRemaining: snap.limitRemaining, + pid: UInt32(getpid()) + ), header: &header) + writeHeader(header) + } + + // MARK: - Reading (app side) + + /// Read the trace. Returns an empty `Trace` when the file is missing, carries + /// a foreign magic/version, or is shorter than the header — never a partial + /// misread. Empty slots and a torn trailing record are skipped. + public static func readAll() -> Trace { + guard let url = fileURL, + let data = try? Data(contentsOf: url), + let header = decodeHeader(data) else { return Trace() } + + var trace = Trace() + let filled = min(Int(header.recordCount), ringCapacity) + // Once wrapped, `writeIndex` points at the OLDEST slot; before that the + // ring starts at 0. + let start = header.recordCount >= UInt32(ringCapacity) ? Int(header.writeIndex) : 0 + for step in 0.. Data { + var out = Data(count: headerSize) + out.writeLE(magic, at: offMagic, width: 4) + out.writeLE(layoutVersion, at: offVersion, width: 2) + out.writeLE(UInt64(recordSize), at: offRecordSize, width: 2) + out.writeLE(UInt64(ringCapacity), at: offCapacity, width: 4) + out.writeLE(UInt64(header.writeIndex), at: offWriteIndex, width: 4) + out.writeLE(UInt64(header.recordCount), at: offRecordCount, width: 4) + out.writeLE(UInt64(header.bootCount), at: offBootCount, width: 4) + return out + } + + static func decodeHeader(_ data: Data) -> Header? { + // Every field is sanity-checked, not just trusted: the header is rewritten + // after each flush, so a write torn by a kill can leave a valid magic with + // a garbage slot index — which would make the reader plot unrelated bytes + // as samples. + guard data.count >= headerSize, + readLE(data, offMagic, 4) == magic, + readLE(data, offVersion, 2) == layoutVersion, + readLE(data, offRecordSize, 2) == recordSize, + readLE(data, offCapacity, 4) == UInt64(ringCapacity) else { return nil } + let writeIndex = UInt32(truncatingIfNeeded: readLE(data, offWriteIndex, 4)) + guard writeIndex < UInt32(ringCapacity) else { return nil } + return Header( + writeIndex: writeIndex, + recordCount: UInt32(truncatingIfNeeded: readLE(data, offRecordCount, 4)), + bootCount: UInt32(truncatingIfNeeded: readLE(data, offBootCount, 4)) + ) + } + + static func encodeRecord(_ record: Record) -> Data { + var out = Data(count: recordSize) + out.writeLE(UInt64(record.kind.rawValue), at: offKind, width: 1) + out.writeLE(UInt64(record.reason), at: offReason, width: 4) + out.writeLE(UInt64(bitPattern: record.wallMs), at: offWallMs, width: 8) + out.writeLE(UInt64(bitPattern: record.uptimeMs), at: offUptimeMs, width: 8) + out.writeLE(record.footprint, at: offFootprint, width: 8) + out.writeLE(record.windowMax, at: offWindowMax, width: 8) + out.writeLE(record.lifetimePeak, at: offLifetimePeak, width: 8) + out.writeLE(record.limitRemaining, at: offLimitRemaining, width: 8) + out.writeLE(UInt64(record.pid), at: offPid, width: 4) + out.writeLE(UInt64(checksum(of: out)), at: offChecksum, width: 4) + return out + } + + /// FNV-1a over the record body, so a slot whose write was torn by a kill is + /// REJECTED instead of decoded as a real (and badly wrong) sample. Pure + /// integer math on purpose: no CryptoKit, keeps this file Foundation-only and + /// byte-identical across platforms. + static func checksum(of data: Data) -> UInt32 { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in data.prefix(offChecksum) { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01b3 + } + return UInt32(truncatingIfNeeded: hash >> 32) + } + + /// `nil` for an empty slot, an unknown kind, a short buffer, or a checksum + /// mismatch — the last two being exactly what a write torn by a kill looks + /// like. Skipping is always right: a half-written record would otherwise be + /// reported as the memory figure of a process that never used it. + static func decodeRecord(_ data: Data?) -> Record? { + guard let data, data.count == recordSize else { return nil } + guard UInt32(truncatingIfNeeded: readLE(data, offChecksum, 4)) == checksum(of: data) + else { return nil } + guard let kind = Kind(rawValue: UInt8(truncatingIfNeeded: readLE(data, offKind, 1))) else { + return nil + } + return Record( + kind: kind, + reason: UInt32(truncatingIfNeeded: readLE(data, offReason, 4)), + wallMs: Int64(bitPattern: readLE(data, offWallMs, 8)), + uptimeMs: Int64(bitPattern: readLE(data, offUptimeMs, 8)), + footprint: readLE(data, offFootprint, 8), + windowMax: readLE(data, offWindowMax, 8), + lifetimePeak: readLE(data, offLifetimePeak, 8), + limitRemaining: readLE(data, offLimitRemaining, 8), + pid: UInt32(truncatingIfNeeded: readLE(data, offPid, 4)) + ) + } + + // MARK: - File primitives + + /// Create the trace file with the protection class the NE needs to keep + /// writing while the device is locked-after-first-unlock, mirroring + /// `ExtensionDiagLog`. A default-protection file silently stops recording + /// exactly during the deliver-while-locked window. + private static func ensureFile(_ url: URL) -> Bool { + let attributes: [FileAttributeKey: Any] = [ + .protectionKey: FileProtectionType.completeUntilFirstUserAuthentication + ] + if FileManager.default.fileExists(atPath: url.path) { + // Re-assert on every open: a file created by another process (or an + // older build) may carry default protection, which blocks writes on a + // locked device. Cheap and idempotent. + try? FileManager.default.setAttributes(attributes, ofItemAtPath: url.path) + return true + } + // Header + live slot + a zero-filled ring. The zero fill is what makes a + // never-written slot distinguishable from a corrupt one, and it is written + // once, at boot, not per sample. + // The App-Group `Library/` directory is not guaranteed to exist. + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + var initial = encodeHeader(Header()) + initial.append(Data(count: recordSize)) + initial.append(Data(count: ringCapacity * recordSize)) + return FileManager.default.createFile(atPath: url.path, contents: initial, attributes: attributes) + } + + /// Append one record into the next ring slot and advance the write index. + /// Caller holds `lock`. + private static func append(_ record: Record, header: inout Header) { + guard let url = fileURL, ensureFile(url) else { return } + let slot = Int(header.writeIndex) % ringCapacity + let offset = ringStartOffset + UInt64(slot) * UInt64(recordSize) + write(data: encodeRecord(record), to: url, at: offset) + header.writeIndex = UInt32((slot + 1) % ringCapacity) + header.recordCount &+= 1 + } + + /// Rewrite the live slot in place. Caller holds `lock`. + private static func writeLive(_ record: Record) { + guard let url = fileURL, ensureFile(url) else { return } + write(data: encodeRecord(record), to: url, at: liveSlotOffset) + } + + /// Rewrite the header. Caller holds `lock`. + private static func writeHeader(_ header: Header) { + guard let url = fileURL, ensureFile(url) else { return } + write(data: encodeHeader(header), to: url, at: 0) + } + + private static func readHeader() -> Header { + guard let url = fileURL, + let data = try? Data(contentsOf: url), + let header = decodeHeader(data) else { return Header() } + return header + } + + /// In-place positional write. No `fsync`: a jetsam SIGKILL does not lose + /// buffered writes (only power loss does), and forcing the sync would add + /// precisely the I/O load observed to accelerate the kill. + private static func write(data: Data, to url: URL, at offset: UInt64) { + guard let handle = try? FileHandle(forWritingTo: url) else { return } + defer { try? handle.close() } + do { + try handle.seek(toOffset: offset) + handle.write(data) + } catch { + // Best-effort side channel: a failed write must never destabilize the + // NE, and there is no safer place to swallow it than here. + } + } + + // MARK: - Byte helpers + + private static func slice(_ data: Data, _ offset: Int, _ count: Int) -> Data? { + guard offset >= 0, count > 0, offset + count <= data.count else { return nil } + let lower = data.startIndex + offset + return data.subdata(in: lower..<(lower + count)) + } + + private static func readLE(_ data: Data, _ offset: Int, _ width: Int) -> UInt64 { + var value: UInt64 = 0 + for i in 0..> (8 * i)) + } + } +} + +// MARK: - Mach sampling + +/// `task_info(TASK_VM_INFO)` reader — the only source of the jetsam-comparable +/// footprint. Foundation re-exports Darwin, so this stays Foundation-only. +enum MemoryFootprint { + + struct Snapshot: Equatable, Sendable { + /// The kill metric. + var footprint: UInt64 = 0 + /// Peak footprint since process launch — catches spikes between samples. + var lifetimePeak: UInt64 = 0 + /// Headroom to the current limit; 0 = unknown (see `NEMemoryTrace` header). + var limitRemaining: UInt64 = 0 + } + + static func sample() -> Snapshot { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t( + MemoryLayout.size / MemoryLayout.size + ) + let kr = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { words in + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), words, &count) + } + } + guard kr == KERN_SUCCESS else { return Snapshot() } + return Snapshot( + footprint: UInt64(info.phys_footprint), + // bitPattern: the ledger value is an int64 and a trap on a negative + // read would take the sampler down inside the NE. + lifetimePeak: UInt64(bitPattern: info.ledger_phys_footprint_peak), + limitRemaining: UInt64(info.limit_bytes_remaining) + ) + } +} + +/// Wall clock + monotonic uptime. The pair is what lets the chart tell a device +/// nap from a memory plateau. +enum Clocks { + + struct Now: Equatable, Sendable { + var wallMs: Int64 + var uptimeMs: Int64 + } + + static func now() -> Now { + var time = timespec() + clock_gettime(CLOCK_UPTIME_RAW, &time) + return Now( + wallMs: Int64(Date().timeIntervalSince1970 * 1000), + uptimeMs: Int64(time.tv_sec) * 1000 + Int64(time.tv_nsec) / 1_000_000 + ) + } +} diff --git a/Tests/ColumbaAppTests/NEMemoryTraceTests.swift b/Tests/ColumbaAppTests/NEMemoryTraceTests.swift new file mode 100644 index 00000000..493b1490 --- /dev/null +++ b/Tests/ColumbaAppTests/NEMemoryTraceTests.swift @@ -0,0 +1,399 @@ +import XCTest +@testable import ColumbaApp + +/// Regression coverage for the Network Extension's self-reported memory trace. +/// +/// Two properties carry real weight here and would silently corrupt the chart if +/// they regressed: +/// +/// 1. A record whose write was torn by a kill must be REJECTED, never decoded. +/// A half-written slot decodes as a plausible-looking sample with the wrong +/// byte counts, and the app would report that as the memory of a process that +/// never used it. +/// 2. A kill is identified by the ABSENCE of a clean-stop marker. iOS delivers no +/// memory-kill callback, and `NEProviderStopReason` has no memory case, so +/// this absence is the only available evidence — the attribution logic must +/// not invent a kill for a clean teardown, and must not miss one either. +final class NEMemoryTraceTests: XCTestCase { + + private var fileURL: URL! + private let base = Date(timeIntervalSince1970: 1_700_000_000) + + override func setUpWithError() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ne-memory-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + fileURL = directory.appendingPathComponent("ne-memory.bin") + NEMemoryTrace.ringCapacityOverride = nil + NEMemoryTrace.fileURLOverride = fileURL + } + + override func tearDown() { + NEMemoryTrace.fileURLOverride = nil + NEMemoryTrace.ringCapacityOverride = nil + try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) + } + + // MARK: - Helpers + + private func wall(_ seconds: TimeInterval) -> Int64 { + Int64(base.addingTimeInterval(seconds).timeIntervalSince1970 * 1000) + } + + private func megabytes(_ value: UInt64) -> UInt64 { value * 1_048_576 } + + private func makeRecord( + _ kind: NEMemoryTrace.Kind, + pid: UInt32, + at seconds: TimeInterval, + uptime overrideUptime: TimeInterval? = nil, + footprint: UInt64 = 0, + windowMax: UInt64 = 0, + lifetimePeak: UInt64 = 0, + limit: UInt64 = 0, + reason: UInt32 = 0 + ) -> NEMemoryTrace.Record { + NEMemoryTrace.Record( + kind: kind, reason: reason, + wallMs: wall(seconds), uptimeMs: Int64((overrideUptime ?? seconds) * 1000), + footprint: footprint, windowMax: windowMax, + lifetimePeak: lifetimePeak, limitRemaining: limit, pid: pid + ) + } + + // MARK: - Byte layout + + func testRecordRoundTripPreservesEveryField() { + let original = NEMemoryTrace.Record( + kind: .stop, reason: 12, + wallMs: wall(7), uptimeMs: -4_294_967_296, + footprint: megabytes(47), windowMax: 1 << 40, + lifetimePeak: 1 << 41, limitRemaining: 1 << 42, pid: 42_424_242 + ) + XCTAssertEqual(NEMemoryTrace.decodeRecord(NEMemoryTrace.encodeRecord(original)), original) + } + + func testTornRecordIsRejectedInsteadOfPlotted() { + let record = makeRecord(.bucket, pid: 7, at: 3, footprint: megabytes(30), lifetimePeak: megabytes(31)) + var bytes = NEMemoryTrace.encodeRecord(record) + // A kill mid-`write()` can commit only the leading bytes of a slot. Zeroing + // the tail is the cheapest faithful stand-in for that. + for index in 56.. NEMemoryTrace.Trace { + NEMemoryTrace.clear() + NEMemoryTrace.appendRecords(records) + return NEMemoryTrace.readAll() + } +} diff --git a/docs/MODEL_B_BACKGROUND_DELIVERY.md b/docs/MODEL_B_BACKGROUND_DELIVERY.md index 5a5ecae8..2f8aa1ed 100644 --- a/docs/MODEL_B_BACKGROUND_DELIVERY.md +++ b/docs/MODEL_B_BACKGROUND_DELIVERY.md @@ -145,6 +145,7 @@ The app's announce action routes through `ProxyRnsBackend.announce` -> IPC -> `N - **Identity:** The app creates the identity in the shared keychain access group; the extension reads it. `AfterFirstUnlockThisDeviceOnly` accessibility permits extension access while locked after first unlock. App Group defaults carry the resolved group name for locked-start cases. - **Message store:** The Python node's inbound rows are written by `rns_bridge` into the shared App-Group GRDB store (WAL + busy_timeout for cross-process access); the app's `ModelBInboundReplay` scans it on the Darwin `newMessage` ping (immediately while open) and on start (catch-up). Outbound delivery-state updates are also owned by the NE. +- **Memory trace:** `Sources/Shared/NEMemoryTrace.swift` is written by the extension (its own `task_info(TASK_VM_INFO).phys_footprint`, sampled on a detached native `Thread` because the NE's main dispatch thread is blocked during bring-up and announce floods) into a fixed 8192-slot ring plus an in-place live slot at `/Library/ne-memory.bin`, and read by the app, which charts it at Settings -> Reticulum Hosting -> Memory Usage (`NEMemoryHistory` + `NEMemoryView`). It lives under `Library/` rather than the App-Group root so `devicectl device copy from` can pull it off a device (that command only exposes Library / Documents / tmp inside a container). One-way and file-only on purpose: iOS gives an app no way to read another process's footprint (`proc_pid_rusage` on a foreign pid is EPERM), and a memory kill delivers no callback at all, so the extension has to record itself and the ABSENCE of its `.stop` marker is the only kill evidence available. Each record carries an FNV-1a checksum so a slot torn open by a `SIGKILL` is skipped rather than plotted as a bogus sample. On each launch the app also snapshots this ring into its own Documents directory as `ne-memory.bin` (`DiagLog.copyMemoryRingToDocuments`, mtime-guarded, once per launch — not on the live-copy loop, so it never doubles the disk-write profile), making the post-kill history pullable via `appDataContainer` even when the App-Group container is not directly reachable. ## Load-bearing invariants diff --git a/docs/MODEL_B_TESTING_TODO.md b/docs/MODEL_B_TESTING_TODO.md index 924d1cb4..6ab5418c 100644 --- a/docs/MODEL_B_TESTING_TODO.md +++ b/docs/MODEL_B_TESTING_TODO.md @@ -69,7 +69,10 @@ DTS 769398). arrived) for each locked delivery — no send-side timeout. Capture lock-screen behavior with a photo/video (no unified-log over WiFi). Pull -the NE log after each run: `devicectl … appDataContainer … Documents/ext-diag.log`. +the NE log after each run: `devicectl … appDataContainer … Documents/ext-diag.log` +— that is the app's launch-time mirror. The NE's LIVE copy is in the App-Group +container at `Library/ext-diag.log` (see cheatsheet); the Documents mirror only +advances while the app runs, so pull the App-Group copy for post-kill forensics. --- @@ -83,14 +86,33 @@ old PoC only measured *idle* (~13.8 MB). This measures *under load*. separate, hard-to-attach process. The trick is to make the **NE log its own memory** and drive load from a desktop peer — no debugger needed. -### 3a. Instrument (one-time, ~30 min) -- [ ] Add a lightweight sampler to the NE: every 250 ms during a receive/reassembly - window, log `os_proc_available_memory()` and `mach_task_basic_info().resident_size` - to `ext-diag.log` (reuse the old `measureRNSFootprint()` sampler from the PoC; - it already exists in git history). 250 ms — the existing 5 s cadence misses - the transient peak. -- [ ] Log `stopTunnel(with:)` `reason`; treat `NEProviderStopReason.memoryLow` (or a - silent mid-reassembly log truncation) as a **kill**. +### 3a. Instrument — DONE (shipped as `NEMemoryTrace`) + +The NE samples itself into a fixed 8192-slot ring in the App-Group container +(`ne-memory.bin`), charted in the app at **Settings → Reticulum Hosting → Memory +Usage**. Four claims in the original plan were wrong; each is verified against the +iOS SDK or Apple's docs, and each one would have produced a confident-but-false +verdict: + +- [x] The kill metric is `task_vm_info.phys_footprint`, **not** + `mach_task_basic_info().resident_size`. The kernel enforces + `memorystatus: … ActiveHard MB` on footprint; RSS also counts shared + file-backed pages, so an RSS number is not comparable to the ceiling. +- [x] `os_proc_available_memory()` cannot serve as the ceiling probe: it returns + `0` for any process that "isn't an app", which an app extension may be, so + `0` can never be read as "at the limit". The trace records + `task_vm_info.limit_bytes_remaining` and treats `0` as *unknown*. +- [x] There is **no `NEProviderStopReason.memoryLow` case** — the enum has no + memory reason at all — and a memory kill is a bare SIGKILL with no + `stopTunnel` callback. A kill is detected as a process generation (pid) + whose records end with **no `.stop` marker**; `stopTunnel` still writes the + reason for every teardown iOS does announce. +- [x] No `measureRNSFootprint()` sampler has ever existed in this repo's history + (`git grep` across every revision finds nothing). `NEMemoryTrace` is new. +- [x] The sampler runs on a **detached native `Thread`**, not a `Task` or a + run-loop `Timer`: the NE's main dispatch thread is blocked during bring-up + and announce floods, so a cooperative sampler records nothing and looks + exactly like a process that never grew. ### 3b. Drive load (desktop peer over the relay) - [ ] **Small/opportunistic burst:** desktop peer sends N opportunistic messages @@ -103,9 +125,19 @@ memory** and drive load from a desktop peer — no debugger needed. - [ ] Run each scenario **5×** to catch variance. ### 3c. Verdict (iPhone 14) -- [ ] **PASS:** peak resident stays **≥ 8 MB below** the `os_proc_available_memory()=0` - point across all 5 runs, **zero** memory-reason stops → Model B holds for that - payload class. +- [ ] **PASS:** peak `phys_footprint` (Memory Usage screen, or the + `ne-memory.bin` ring) stays **≥ 8 MB below** the ceiling this device + actually enforces — read that number from `idevicesyslog` + (`memorystatus: … ActiveHard MB`), never from + `os_proc_available_memory()`, which may report 0 regardless — across all 5 + runs, and **zero** generations end without a stop marker → Model B holds + for that payload class. +- [ ] Cross-check the app's kill inference against the kernel: `idevicesyslog` + emits a `jetsamEvent` line naming the killed process (and the + `memorystatus` line right before it states the limit that was exceeded). + Every "stopped unexpectedly" card on the Memory Usage screen should have a + matching `jetsamEvent`. A kernel kill the chart missed means the stop + marker was written too late — a `NEMemoryTrace` bug, not a false positive. - [ ] **CONDITIONAL:** small fits but the large attachment breaches → ship small- payload delivery now; large-payload delivery gated on finishing Track L streaming (L2/L3 landed; verify the peak is window-bounded, not payload-bounded). @@ -113,9 +145,13 @@ memory** and drive load from a desktop peer — no debugger needed. sniff-only + complete-on-open and re-scope. > Shortcut now that delivery actually works: you can get a first read **without** -> the harness — drive real traffic from the Android peer, then pull `ext-diag.log` -> and read the sampler line just before/after the transfer. The controlled -> desktop-peer harness is only needed for the clean 5-run numbers in the verdict. +> the harness — drive real traffic from the Android peer, then open +> **Settings → Reticulum Hosting → Memory Usage**, or pull the ring straight off +> the device: `xcrun devicectl device copy from --device --domain-type +> appGroupDataContainer --domain-identifier group.network.columba.Columba +> --source Library/ne-memory.bin --destination /tmp/ne-memory.bin`). The +> controlled desktop-peer harness +> is only needed for the clean 5-run numbers in the verdict. --- @@ -145,6 +181,8 @@ memory** and drive load from a desktop peer — no debugger needed. - Device: iPhone 14, devicectl id ``. Get the **current LAN IP** from the NE socket / `lsof`, not memory (DHCP moves it). - Pull NE log: `xcrun devicectl device copy from --device --domain-type appDataContainer --domain-identifier network.columba.Columba --source Documents/ext-diag.log --destination /tmp/ext-diag.log` +- Pull NE log (LIVE, App-Group): same but `--domain-type appGroupDataContainer --domain-identifier group.network.columba.Columba --source Library/ext-diag.log`. The appDataContainer copy above is the app's launch-time snapshot; the App-Group copy is the authoritative post-kill source (devicectl only exposes Library/Documents/tmp inside a container, which is why the log lives under `Library/`). +- Pull the NE memory ring from the app container (no App-Group access needed): `--domain-type appDataContainer --source Documents/ne-memory.bin` — a launch-time snapshot via `DiagLog.copyMemoryRingToDocuments`; for the live ring pull `Library/ne-memory.bin` from the App Group as in §3. - Pull app log: same, `--source Documents/diag.log`. - The shared **GRDB DB** can't be pulled live (devicectl `..` bug + the NE holds it open). Use the in-app `[DIAG-STORE]` log instead, or copy the DB to Documents