diff --git a/Sources/ColumbaApp/Services/NEMemoryHistory.swift b/Sources/ColumbaApp/Services/NEMemoryHistory.swift index 0f42cd73..9521d80e 100644 --- a/Sources/ColumbaApp/Services/NEMemoryHistory.swift +++ b/Sources/ColumbaApp/Services/NEMemoryHistory.swift @@ -41,6 +41,15 @@ enum NEMemoryHistory { let footprintMB: Double /// Highest footprint inside the window (MB) — the spike envelope. let windowMaxMB: Double + /// Cumulative bytes this generation had written to disk at this point + /// (MB); monotonic within a segment, resets at a process restart. nil + /// when the probe failed for this record (sentinel counter). + let diskWrittenMB: Double? + /// Disk-write rate over the interval since the previous point in the + /// same segment (KB/s); nil for a segment's first point. Sustained rate + /// above `dailyDiskWriteBudgetBytes / 86_400` is what gets the NE + /// killed by the disk-writes resource limit. + let diskRateKBs: Double? let segment: Int let pid: UInt32 } @@ -64,6 +73,9 @@ enum NEMemoryHistory { /// Headroom to the limit at the last sample (MB); nil when the OS /// reported no limit for this process. let headroomMB: Double? + /// Total bytes that generation wrote to disk (MB), when the trace + /// carries the counter; nil for boot markers and pre-v2 rings. + let diskWrittenMB: Double? } /// The newest generation, while it is still up. @@ -159,6 +171,13 @@ enum NEMemoryHistory { static let daySeconds: TimeInterval = 86_400 + /// Daily disk-write budget for a background process, as the OS states it in + /// the `*.diskwrites_resource` `.ips` reports pulled off the iPhone 14 + /// ("limit of 12,137 KB … over 86400 seconds" — ~0.14 KB/s sustained). The + /// reference line the disk chart draws; a generation whose cumulative + /// writes cross this gets throttled, then killed. + static let dailyDiskWriteBudgetBytes: UInt64 = 12_137 * 1024 + // MARK: - Analysis /// Whether the tunnel process is believed alive, gone, or mid-transition. @@ -216,11 +235,20 @@ enum NEMemoryHistory { 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 + lastSeenMB: nil, peakMB: nil, headroomMB: nil, diskWrittenMB: nil )) eventId += 1 } + // The counter is cumulative since process start (rusage counters + // begin at 0 at exec), so the generation's lifetime total is simply + // the LAST reading — no `last − first` subtraction, which would + // drop the run's early writes once the ring overwrites its boot + // marker. 0 or the sentinel means no usable counter (pre-v2 ring, + // failed probes). + let diskTotalMB: Double? = last.diskWritten > 0 && last.diskWritten != NEMemoryTrace.diskWrittenUnavailable + ? megabytes(last.diskWritten) : nil + 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 @@ -237,7 +265,8 @@ enum NEMemoryHistory { 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 + headroomMB: last.limitRemaining > 0 ? megabytes(last.limitRemaining) : nil, + diskWrittenMB: diskTotalMB )) eventId += 1 } else if endedCleanly { @@ -246,11 +275,18 @@ enum NEMemoryHistory { 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 + headroomMB: last.limitRemaining > 0 ? megabytes(last.limitRemaining) : nil, + diskWrittenMB: diskTotalMB )) eventId += 1 } + // The disk counter resets at process start, so rate and total are + // per-generation. `previousPlotted` is the last plotted record in + // THIS generation: its delta covers exactly the interval between + // plotted points, with no live/bucket double counting. + var previousPlotted: (date: Date, disk: UInt64)? + for record in generation { let date = date(fromMs: record.wallMs) if let previous = previousDate { @@ -275,10 +311,28 @@ enum NEMemoryHistory { // 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 } + var diskRateKBs: Double? + if record.diskWritten != NEMemoryTrace.diskWrittenUnavailable { + if let previous = previousPlotted, record.diskWritten >= previous.disk { + let interval = date.timeIntervalSince(previous.date) + if interval > 0 { + diskRateKBs = Double(record.diskWritten - previous.disk) / 1024 / interval + } + } + previousPlotted = (date, record.diskWritten) + } else { + // A failed probe carries no counter: no rate for this point, + // and the next point's rate must not bridge the gap (its + // delta would span the failed interval, not its own). + previousPlotted = nil + } points.append(Point( id: pointId, date: date, footprintMB: megabytes(record.footprint), windowMaxMB: megabytes(record.windowMax), + diskWrittenMB: record.diskWritten == NEMemoryTrace.diskWrittenUnavailable + ? nil : megabytes(record.diskWritten), + diskRateKBs: diskRateKBs, segment: segment, pid: record.pid )) pointId += 1 diff --git a/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift b/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift index f3dcb019..cda2acf7 100644 --- a/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift +++ b/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift @@ -60,6 +60,7 @@ struct NEMemoryView: View { statsRow rangePicker chartCard + diskChartCard explainer } .padding(20) @@ -110,6 +111,11 @@ struct NEMemoryView: View { .font(.footnote) .foregroundStyle(Theme.textSecondary) } + if let disk = kill.diskWrittenMB { + Text("It wrote \(String(format: "%.1f", disk)) MB to disk during its run — the daily budget iOS enforces on background processes is \(String(format: "%.1f", Double(NEMemoryHistory.dailyDiskWriteBudgetBytes) / 1_048_576)) MB.") + .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) @@ -264,6 +270,60 @@ struct NEMemoryView: View { } } + /// Disk-write rate vs the budget the OS enforces, expressed as a rate. The + /// y-value is the average over each sample window, so bursts and idle gaps + /// are both visible; the dashed line is the rate that stays inside the + /// daily write budget when sustained. (A cumulative chart against the fixed + /// daily budget was tried first: a multi-day healthy run crosses the line + /// and reads as a breach, because the budget renews daily — rate does not + /// have that problem.) + @ViewBuilder private var diskChartCard: some View { + if !visiblePoints.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Disk writes").font(.headline) + Chart { + ForEach(visiblePoints) { point in + // A point with no rate (segment start, or a failed + // probe) plots nothing rather than a fake zero. + if let rate = point.diskRateKBs { + LineMark( + x: .value("Time", point.date), + y: .value("Rate", rate), + series: .value("Run", "\(point.segment)") + ) + .foregroundStyle(Theme.accentColor) + .interpolationMethod(.linear) + } + } + RuleMark(y: .value("Budget rate", diskBudgetKBs)) + .foregroundStyle(Color.orange) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + .annotation(position: .top, alignment: .leading) { + Text("daily average") + .font(.caption2) + .foregroundStyle(Color.orange) + } + } + .chartYAxisLabel("KB/s", position: .leading) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.hour().day()) + } + } + .frame(height: 140) + .accessibilityIdentifier("ne_disk_chart") + + Text("How fast the background transport writes to disk, averaged over each sample window. The dashed line is the average rate that stays inside the per-day write budget iOS enforces (12,137 KB/day); bursts above it are normal — what kills the process is sustaining more than that over a day.") + .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) @@ -303,6 +363,12 @@ struct NEMemoryView: View { return limit > current.footprintMB + 1 ? limit : nil } + /// The daily disk-write budget the OS enforces on this process class, + /// expressed as an average rate (12,137 KB over 86,400 s ≈ 0.14 KB/s). + private var diskBudgetKBs: Double { + Double(NEMemoryHistory.dailyDiskWriteBudgetBytes) / 1024 / 86_400 + } + private func formatMB(_ value: Double?) -> String { guard let value else { return "—" } return String(format: "%.1f MB", value) diff --git a/Sources/Shared/NEMemoryTrace.swift b/Sources/Shared/NEMemoryTrace.swift index 14cd0cfb..c7489fc5 100644 --- a/Sources/Shared/NEMemoryTrace.swift +++ b/Sources/Shared/NEMemoryTrace.swift @@ -58,14 +58,17 @@ public enum NEMemoryTrace { /// 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 + static let layoutVersion: UInt64 = 2 /// 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 + /// v2 grew the record by 8 B for `diskWritten` (see the field doc); an + /// on-disk v1 file has a different size and is dropped on first open (see + /// `ensureFile`) — it is diagnostic data, not state worth migrating. + static let recordSize = 72 static let headerSize = 64 - /// Ring capacity in records. 8192 × 64 B = 512 KB sparse file; at the 1/min + /// Ring capacity in records. 8192 × 72 B ≈ 576 KB sparse file; at the 1/min /// idle flush cadence that is ~5.6 days of history. static let defaultRingCapacity = 8192 @@ -92,8 +95,12 @@ public enum NEMemoryTrace { 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) + static let offDiskWritten = 56 // u64 (cumulative bytes written to disk) + /// Sentinel for `diskWritten`: the rusage probe failed, so no disk data + /// exists for this record. Never a real counter value — 4 EiB of writes. + static let diskWrittenUnavailable = UInt64.max + static let offPid = 64 // u32 + static let offChecksum = 68 // u32 (FNV-1a over bytes 0..<68) /// Header field offsets. static let offMagic = 0 // u32 @@ -158,6 +165,14 @@ public enum NEMemoryTrace { public let lifetimePeak: UInt64 /// `limit_bytes_remaining`; 0 means unknown (see file header). public let limitRemaining: UInt64 + /// Cumulative bytes this process has written to disk at record time + /// (`proc_pid_rusage` → `ri_diskio_byteswritten`). Monotonic within a + /// generation; resets to ~0 at each process start, so per-generation + /// totals are `last − first` and per-interval rates are the delta + /// between consecutive records. This is the volume the OS disk-writes + /// resource limit measures (the `.ips` reports), whose breach gets the + /// NE throttled or killed. + public let diskWritten: 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 @@ -165,7 +180,7 @@ public enum NEMemoryTrace { public init( kind: Kind, reason: UInt32, wallMs: Int64, uptimeMs: Int64, footprint: UInt64, windowMax: UInt64, lifetimePeak: UInt64, - limitRemaining: UInt64, pid: UInt32 + limitRemaining: UInt64, diskWritten: UInt64 = 0, pid: UInt32 ) { self.kind = kind self.reason = reason @@ -175,6 +190,7 @@ public enum NEMemoryTrace { self.windowMax = windowMax self.lifetimePeak = lifetimePeak self.limitRemaining = limitRemaining + self.diskWritten = diskWritten self.pid = pid } } @@ -304,6 +320,9 @@ public enum NEMemoryTrace { guard isRunning(generation: generation) else { return } let snap = MemoryFootprint.sample() + // Probe failure → sentinel, never 0: a zero between real readings + // would fake a counter reset and inflate the next interval's rate. + let diskWritten = DiskIO.sample()?.writtenBytes ?? diskWrittenUnavailable let now = Clocks.now() let pid = UInt32(getpid()) // A jump is measured against what the last bucket recorded, so a @@ -320,7 +339,9 @@ public enum NEMemoryTrace { wallMs: now.wallMs, uptimeMs: now.uptimeMs, footprint: snap.footprint, windowMax: windowMax, lifetimePeak: snap.lifetimePeak, - limitRemaining: snap.limitRemaining, pid: pid + limitRemaining: snap.limitRemaining, + diskWritten: diskWritten, + pid: pid )) lastLiveUptimeMs = now.uptimeMs } @@ -331,7 +352,9 @@ public enum NEMemoryTrace { wallMs: now.wallMs, uptimeMs: now.uptimeMs, footprint: snap.footprint, windowMax: windowMax, lifetimePeak: snap.lifetimePeak, - limitRemaining: snap.limitRemaining, pid: pid + limitRemaining: snap.limitRemaining, + diskWritten: diskWritten, + pid: pid ), header: &header) writeHeader(header) lastFlushUptimeMs = now.uptimeMs @@ -400,6 +423,7 @@ public enum NEMemoryTrace { private static func appendMarkerLocked(kind: Kind, reason: UInt32) { let snap = MemoryFootprint.sample() + let diskWritten = DiskIO.sample()?.writtenBytes ?? diskWrittenUnavailable let now = Clocks.now() var header = readHeader() if kind == .boot { header.bootCount &+= 1 } @@ -409,6 +433,7 @@ public enum NEMemoryTrace { footprint: snap.footprint, windowMax: snap.lifetimePeak, lifetimePeak: snap.lifetimePeak, limitRemaining: snap.limitRemaining, + diskWritten: diskWritten, pid: UInt32(getpid()) ), header: &header) writeHeader(header) @@ -513,6 +538,7 @@ public enum NEMemoryTrace { 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(record.diskWritten, at: offDiskWritten, width: 8) out.writeLE(UInt64(record.pid), at: offPid, width: 4) out.writeLE(UInt64(checksum(of: out)), at: offChecksum, width: 4) return out @@ -551,6 +577,7 @@ public enum NEMemoryTrace { windowMax: readLE(data, offWindowMax, 8), lifetimePeak: readLE(data, offLifetimePeak, 8), limitRemaining: readLE(data, offLimitRemaining, 8), + diskWritten: readLE(data, offDiskWritten, 8), pid: UInt32(truncatingIfNeeded: readLE(data, offPid, 4)) ) } @@ -566,11 +593,23 @@ public enum NEMemoryTrace { .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 + // Reject a file whose size does not match THIS build's layout (a v1 + // ring from an older build, or a torn creation): slot addressing + // differs, so writing would interleave two layouts. The trace is + // diagnostics, not state — drop and recreate rather than migrate. + let expectedSize = Int(headerSize) + recordSize * (1 + ringCapacity) + let size = (try? FileManager.default.attributesOfItem(atPath: url.path)) + .flatMap { $0[.size] as? Int } ?? 0 + if size != expectedSize { + try? FileManager.default.removeItem(at: url) + } + 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 @@ -692,6 +731,71 @@ enum MemoryFootprint { } } +// MARK: - Disk I/O sampling + +/// Cumulative disk-write counter for THIS process, from +/// `proc_pid_rusage(RUSAGE_INFO_V6)`. `ri_diskio_byteswritten` counts physical +/// writes after page-cache writeback — the closest public probe to the +/// "file backed memory dirtied" volume the OS disk-writes resource limit +/// measures (the `*.diskwrites_resource` `.ips` reports). +/// +/// `proc_pid_rusage` is NOT declared in the iOS SDK's headers (libproc.h ships +/// only on macOS), but the symbol has been in libsystem since iOS 8 and is +/// callable for the current process without entitlement — resolve it once via +/// `dlsym(RTLD_DEFAULT, …)`. Reading your own rusage needs no privilege. +enum DiskIO { + + struct Snapshot: Equatable, Sendable { + /// Physical bytes written since process start. + var writtenBytes: UInt64 = 0 + /// Logical writes (the dirtying-side counter, same source). + var logicalWrites: UInt64 = 0 + } + + private typealias ProcPidRusageFn = @convention(c) (Int32, Int32, UnsafeMutableRawPointer) -> Int32 + + private static let procPidRusage: ProcPidRusageFn? = { + // Darwin's RTLD_DEFAULT is (void*)-2, NOT NULL — a glibc-ism cost a + // device cycle here: dlsym(NULL, …) fails outright on iOS. -2 is never + // 0, so the failable bitPattern init cannot trip. + guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2)!, "proc_pid_rusage") else { + return nil + } + return unsafeBitCast(sym, to: ProcPidRusageFn.self) + }() + + /// nil = the probe failed (symbol missing or syscall error). The caller must + /// NOT store 0 for this: 0 is a legitimate counter reading, and a zero + /// between real readings would fake a counter reset and inflate the next + /// interval's rate. The sampler stores the `diskWrittenUnavailable` + /// sentinel instead, which the analyzer renders as "no data". + static func sample() -> Snapshot? { + guard let fn = procPidRusage else { + logFailureOnce("proc_pid_rusage symbol not found") + return nil + } + var info = rusage_info_v6() + let result = withUnsafeMutablePointer(to: &info) { pointer in + fn(getpid(), RUSAGE_INFO_V6, UnsafeMutableRawPointer(pointer)) + } + guard result == 0 else { + logFailureOnce("proc_pid_rusage returned \(result), errno \(errno)") + return nil + } + return Snapshot(writtenBytes: info.ri_diskio_byteswritten, logicalWrites: info.ri_logical_writes) + } + + /// The failure is persistent-or-never for a given process, so one line is + /// all the diagnosis the rolling log needs. + private static func logFailureOnce(_ detail: String) { + guard !loggedFailure else { return } + loggedFailure = true + ExtensionDiagLog.log("MEMTRACE disk probe unavailable: \(detail)") + } + + private static var loggedFailure = false +} + /// Wall clock + monotonic uptime. The pair is what lets the chart tell a device /// nap from a memory plateau. enum Clocks { diff --git a/Tests/ColumbaAppTests/NEMemoryTraceTests.swift b/Tests/ColumbaAppTests/NEMemoryTraceTests.swift index 493b1490..4f9fd404 100644 --- a/Tests/ColumbaAppTests/NEMemoryTraceTests.swift +++ b/Tests/ColumbaAppTests/NEMemoryTraceTests.swift @@ -51,13 +51,15 @@ final class NEMemoryTraceTests: XCTestCase { windowMax: UInt64 = 0, lifetimePeak: UInt64 = 0, limit: UInt64 = 0, + disk: 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 + lifetimePeak: lifetimePeak, limitRemaining: limit, + diskWritten: disk, pid: pid ) } @@ -68,7 +70,8 @@ final class NEMemoryTraceTests: XCTestCase { 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 + lifetimePeak: 1 << 41, limitRemaining: 1 << 42, + diskWritten: 0xF0F1_F2F3_F4F5_F6F7, pid: 42_424_242 ) XCTAssertEqual(NEMemoryTrace.decodeRecord(NEMemoryTrace.encodeRecord(original)), original) } @@ -86,6 +89,37 @@ final class NEMemoryTraceTests: XCTestCase { XCTAssertNil(NEMemoryTrace.decodeRecord(Data(count: NEMemoryTrace.recordSize))) } + /// A trace file from an incompatible layout (an older build's ring) has a + /// different size, so slot addressing differs. The writer must drop and + /// recreate it rather than interleave two layouts; the reader must see the + /// new records, not garbage from the old geometry. + func testForeignLayoutFileIsDroppedAndRecreated() throws { + // A v1 ring: same header geometry, but 64 B records — a different size + // than this build writes. + func le(_ value: UInt64, _ width: Int) -> [UInt8] { + (0..> (8 * $0)) } + } + let v1RecordSize = 64 + var legacy = Data(count: NEMemoryTrace.headerSize) + for (offset, field) in [ + (NEMemoryTrace.offMagic, le(NEMemoryTrace.magic, 4)), + (NEMemoryTrace.offVersion, le(1, 2)), + (NEMemoryTrace.offRecordSize, le(UInt64(v1RecordSize), 2)), + (NEMemoryTrace.offCapacity, le(UInt64(NEMemoryTrace.ringCapacity), 4)), + ] { + for (index, byte) in field.enumerated() { + legacy[legacy.startIndex + offset + index] = byte + } + } + legacy.append(Data(count: v1RecordSize * (1 + NEMemoryTrace.ringCapacity))) + try legacy.write(to: fileURL, options: [.atomic]) + + NEMemoryTrace.appendRecords([makeRecord(.bucket, pid: 1, at: 1, footprint: megabytes(10))]) + + let trace = NEMemoryTrace.readAll() + XCTAssertEqual(trace.records.map { $0.footprint }, [megabytes(10)]) + } + func testShortBufferIsRejected() { let record = makeRecord(.bucket, pid: 7, at: 3, footprint: megabytes(30)) let encoded = NEMemoryTrace.encodeRecord(record) @@ -147,9 +181,9 @@ final class NEMemoryTraceTests: XCTestCase { func testGenerationWithoutStopMarkerIsAttributedAsKill() throws { let trace = traceOf([ - makeRecord(.boot, pid: 100, at: 0, footprint: megabytes(8), lifetimePeak: megabytes(8)), - makeRecord(.bucket, pid: 100, at: 10, footprint: megabytes(20), lifetimePeak: megabytes(20)), - makeRecord(.bucket, pid: 100, at: 20, footprint: megabytes(45), lifetimePeak: megabytes(49)), + makeRecord(.boot, pid: 100, at: 0, footprint: megabytes(8), lifetimePeak: megabytes(8), disk: 100 * 1024), + makeRecord(.bucket, pid: 100, at: 10, footprint: megabytes(20), lifetimePeak: megabytes(20), disk: 300 * 1024), + makeRecord(.bucket, pid: 100, at: 20, footprint: megabytes(45), lifetimePeak: megabytes(49), disk: 700 * 1024), makeRecord(.boot, pid: 200, at: 30, footprint: megabytes(9), lifetimePeak: megabytes(9)), makeRecord(.bucket, pid: 200, at: 40, footprint: megabytes(12), lifetimePeak: megabytes(12)), ]) @@ -160,6 +194,16 @@ final class NEMemoryTraceTests: XCTestCase { XCTAssertEqual(kill.date, base.addingTimeInterval(20), "attributed to the last sample the dead process wrote") XCTAssertEqual(kill.lastSeenMB ?? -1, 45.0, accuracy: 0.001) XCTAssertEqual(kill.peakMB ?? -1, 49.0, accuracy: 0.001, "peak comes from the lifetime peak, not the last sample") + // The counter is cumulative since process start, so the lifetime total + // is the last reading outright — the boot marker's 100 KB is included, + // and nothing depends on the first surviving record. + XCTAssertEqual(kill.diskWrittenMB ?? -1, 700.0 * 1024 / 1_048_576, accuracy: 0.001) + // Rate between the last two plotted points of the killed generation. + let points = summary.points.filter { $0.pid == 100 } + XCTAssertEqual(points.last?.diskRateKBs ?? -1, 40.0, accuracy: 0.001, "(700−300) KB over 10 s") + // A new process restarts the counter; its first point has no rate. + XCTAssertEqual(summary.points.last?.diskWrittenMB ?? -1, 0, accuracy: 0.001) + XCTAssertNil(summary.points.last?.diskRateKBs) XCTAssertEqual(summary.restarts24h, 2) XCTAssertEqual(summary.current?.footprintMB ?? -1, 12.0, accuracy: 0.001) } @@ -208,6 +252,46 @@ final class NEMemoryTraceTests: XCTestCase { XCTAssertEqual(NEMemoryHistory.mergedRecords(trace).count, 3) } + /// The ring holds ~5.6 days, so a long-lived generation loses its boot + /// marker. The lifetime total must survive that: it reads the LAST + /// cumulative counter (which counts from process start), not + /// `last − first` of the surviving records. + func testWrappedRingDoesNotDropEarlyWritesFromTheKillTotal() throws { + let trace = traceOf([ + makeRecord(.boot, pid: 100, at: 0, footprint: megabytes(8), disk: 19 * 1_048_576), + makeRecord(.bucket, pid: 100, at: 10, footprint: megabytes(20), disk: 20 * 1_048_576), + makeRecord(.bucket, pid: 100, at: 20, footprint: megabytes(21), disk: 21 * 1_048_576), + ]) + let summary = NEMemoryHistory.analyze(trace, tunnelState: .gone, now: base.addingTimeInterval(120)) + + let kill = try XCTUnwrap(summary.lastKill) + XCTAssertEqual(kill.diskWrittenMB ?? -1, 21.0, accuracy: 0.001, + "the 19 MB written before the ring wrapped must still be in the total") + } + + /// A failed rusage probe stores the sentinel, never 0: 0 is a legitimate + /// counter reading, and a zero between real readings would fake a counter + /// reset and inflate the next interval's rate. + func testFailedProbeIsNoDataRatherThanZero() { + let sentinel = NEMemoryTrace.diskWrittenUnavailable + let trace = traceOf([ + makeRecord(.bucket, pid: 1, at: 0, footprint: megabytes(10), disk: 100 * 1024), + makeRecord(.bucket, pid: 1, at: 60, footprint: megabytes(11), disk: sentinel), + makeRecord(.bucket, pid: 1, at: 120, footprint: megabytes(12), disk: 300 * 1024), + ]) + let points = NEMemoryHistory.analyze(trace, tunnelState: .alive, now: base.addingTimeInterval(1_000)).points + + XCTAssertNil(points[1].diskWrittenMB, "the failed probe must plot as no data, not zero") + XCTAssertNil(points[1].diskRateKBs) + XCTAssertNil(points[2].diskRateKBs, "the next point must not bridge the failed interval") + // And a generation whose last reading failed has no total to report. + let summary = NEMemoryHistory.analyze(traceOf([ + makeRecord(.boot, pid: 9, at: 0, footprint: megabytes(8), disk: 100 * 1024), + makeRecord(.bucket, pid: 9, at: 10, footprint: megabytes(9), disk: sentinel), + ]), tunnelState: .gone, now: base.addingTimeInterval(120)) + XCTAssertNil(summary.lastKill?.diskWrittenMB) + } + // MARK: - Chart continuity func testDeviceSleepGapBreaksTheLine() {