From f1d19e83cfd4b0b294b7b483b0eee2fb333c6959 Mon Sep 17 00:00:00 2001 From: torlando-tech <239676438+torlando-tech@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:17:36 +0000 Subject: [PATCH 1/7] Memory screen: track the NE's disk writes against the daily budget The iPhone 14 .ips reports that get the NE throttled/killed are diskwrites_resource breaches - 'limit of 12,137 KB over 86400 s' - which the memory chart could not show because the trace had no disk counter. Trace layout v2 (+8 B record): each record now carries the cumulative proc_pid_rusage ri_diskio_byteswritten at sample time, sampled alongside the footprint by the same 1 Hz loop and included on boot/stop markers so kill cards show the dead generation's total. proc_pid_rusage is not declared in the iOS SDK headers; resolve it via dlsym(RTLD_DEFAULT) - reading your own rusage needs no entitlement. ensureFile now drops a file whose size does not match this build's layout (a v1 ring from an older build) instead of interleaving two geometries. Analyzer derives per-point write rates (delta between consecutive plotted records within a generation - no live/bucket double counting) and per-event disk totals; the screen gains a cumulative-writes chart with the 11.9 MB/day budget line. --- .../ColumbaApp/Services/NEMemoryHistory.swift | 51 ++++++++- .../Views/Settings/NEMemoryView.swift | 59 ++++++++++ Sources/Shared/NEMemoryTrace.swift | 103 +++++++++++++++--- .../ColumbaAppTests/NEMemoryTraceTests.swift | 44 +++++++- 4 files changed, 236 insertions(+), 21 deletions(-) diff --git a/Sources/ColumbaApp/Services/NEMemoryHistory.swift b/Sources/ColumbaApp/Services/NEMemoryHistory.swift index 0f42cd73..1efc29eb 100644 --- a/Sources/ColumbaApp/Services/NEMemoryHistory.swift +++ b/Sources/ColumbaApp/Services/NEMemoryHistory.swift @@ -41,6 +41,14 @@ 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. + 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 +72,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 +170,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 +234,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 } + // Per-generation disk total: `last − first` of the cumulative + // counter. `first.diskWritten == 0` also covers a pre-v2 ring, where + // no record ever carries the counter. + let diskTotalMB: Double? = { + let firstDisk = generation[0].diskWritten + guard last.diskWritten > firstDisk else { return nil } + return megabytes(last.diskWritten - firstDisk) + }() + 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 +264,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 +274,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 +310,20 @@ 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 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) points.append(Point( id: pointId, date: date, footprintMB: megabytes(record.footprint), windowMaxMB: megabytes(record.windowMax), + diskWrittenMB: 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..28e23953 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,54 @@ struct NEMemoryView: View { } } + /// Cumulative disk writes vs the daily budget the OS enforces. The y-value + /// is a running total (resets at each process restart, hence one line per + /// segment), so the slope IS the write rate — a flat line means nothing is + /// being written, and crossing the dashed line is what gets the process + /// throttled, then killed. + @ViewBuilder private var diskChartCard: some View { + if !visiblePoints.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Disk writes").font(.headline) + Chart { + ForEach(visiblePoints) { point in + LineMark( + x: .value("Time", point.date), + y: .value("Written", point.diskWrittenMB), + series: .value("Run", "\(point.segment)") + ) + .foregroundStyle(Theme.accentColor) + .interpolationMethod(.linear) + } + RuleMark(y: .value("Daily budget", diskBudgetMB)) + .foregroundStyle(Color.orange) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + .annotation(position: .top, alignment: .leading) { + Text("daily budget") + .font(.caption2) + .foregroundStyle(Color.orange) + } + } + .chartYAxisLabel("MB", position: .leading) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.hour().day()) + } + } + .frame(height: 140) + .accessibilityIdentifier("ne_disk_chart") + + Text("Cumulative bytes the background transport has written to disk since it started — one line per process run. The dashed line is the per-day write budget iOS enforces; breaching it throttles the process, and continued breaches kill it.") + .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 +357,11 @@ struct NEMemoryView: View { return limit > current.footprintMB + 1 ? limit : nil } + /// The daily disk-write budget the OS enforces on this process class, in MB. + private var diskBudgetMB: Double { + Double(NEMemoryHistory.dailyDiskWriteBudgetBytes) / 1_048_576 + } + 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..c21f63da 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,9 @@ 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) + 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 +162,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 +177,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 +187,7 @@ public enum NEMemoryTrace { self.windowMax = windowMax self.lifetimePeak = lifetimePeak self.limitRemaining = limitRemaining + self.diskWritten = diskWritten self.pid = pid } } @@ -304,6 +317,7 @@ public enum NEMemoryTrace { guard isRunning(generation: generation) else { return } let snap = MemoryFootprint.sample() + let disk = DiskIO.sample() let now = Clocks.now() let pid = UInt32(getpid()) // A jump is measured against what the last bucket recorded, so a @@ -320,7 +334,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: disk.writtenBytes, + pid: pid )) lastLiveUptimeMs = now.uptimeMs } @@ -331,7 +347,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: disk.writtenBytes, + pid: pid ), header: &header) writeHeader(header) lastFlushUptimeMs = now.uptimeMs @@ -400,6 +418,7 @@ public enum NEMemoryTrace { private static func appendMarkerLocked(kind: Kind, reason: UInt32) { let snap = MemoryFootprint.sample() + let disk = DiskIO.sample() let now = Clocks.now() var header = readHeader() if kind == .boot { header.bootCount &+= 1 } @@ -409,6 +428,7 @@ public enum NEMemoryTrace { footprint: snap.footprint, windowMax: snap.lifetimePeak, lifetimePeak: snap.lifetimePeak, limitRemaining: snap.limitRemaining, + diskWritten: disk.writtenBytes, pid: UInt32(getpid()) ), header: &header) writeHeader(header) @@ -513,6 +533,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 +572,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 +588,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 + guard size == expectedSize else { + 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 +726,49 @@ 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? = { + // NULL handle = RTLD_DEFAULT: search the global symbol scope, where + // libsystem_kernel/libproc are always present. + guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: 0), "proc_pid_rusage") else { + return nil + } + return unsafeBitCast(sym, to: ProcPidRusageFn.self) + }() + + static func sample() -> Snapshot { + guard let fn = procPidRusage else { return Snapshot() } + var info = rusage_info_v6() + let result = withUnsafeMutablePointer(to: &info) { pointer in + fn(getpid(), RUSAGE_INFO_V6, UnsafeMutableRawPointer(pointer)) + } + guard result == 0 else { return Snapshot() } + return Snapshot(writtenBytes: info.ri_diskio_byteswritten, logicalWrites: info.ri_logical_writes) + } +} + /// 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..d7cc00e0 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,28 @@ 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. + let v1RecordSize = 64 + var legacy = Data(count: NEMemoryTrace.headerSize) + legacy.writeLE(NEMemoryTrace.magic, at: NEMemoryTrace.offMagic, width: 4) + legacy.writeLE(1, at: NEMemoryTrace.offVersion, width: 2) + legacy.writeLE(UInt64(v1RecordSize), at: NEMemoryTrace.offRecordSize, width: 2) + legacy.writeLE(UInt64(NEMemoryTrace.ringCapacity), at: NEMemoryTrace.offCapacity, width: 4) + 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 +172,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 +185,15 @@ 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") + // Cumulative counter, so the generation total is last − first (the boot + // marker's 100 KB is part of what that process wrote). + XCTAssertEqual(kill.diskWrittenMB ?? -1, 600.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) } From 2a9f02d9242c0e548c6a08ab01df8bee9ae8557b Mon Sep 17 00:00:00 2001 From: torlando-tech <239676438+torlando-tech@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:19:49 +0000 Subject: [PATCH 2/7] Fix guard fallthrough in ensureFile layout check --- Sources/Shared/NEMemoryTrace.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Shared/NEMemoryTrace.swift b/Sources/Shared/NEMemoryTrace.swift index c21f63da..b06d815f 100644 --- a/Sources/Shared/NEMemoryTrace.swift +++ b/Sources/Shared/NEMemoryTrace.swift @@ -595,7 +595,7 @@ public enum NEMemoryTrace { let expectedSize = Int(headerSize) + recordSize * (1 + ringCapacity) let size = (try? FileManager.default.attributesOfItem(atPath: url.path)) .flatMap { $0[.size] as? Int } ?? 0 - guard size == expectedSize else { + if size != expectedSize { try? FileManager.default.removeItem(at: url) } if FileManager.default.fileExists(atPath: url.path) { From 61e1f5b4bd9b1e3319a6bc08974e184e4cc37a11 Mon Sep 17 00:00:00 2001 From: torlando-tech <239676438+torlando-tech@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:21:44 +0000 Subject: [PATCH 3/7] Test: build legacy header without fileprivate writeLE --- Tests/ColumbaAppTests/NEMemoryTraceTests.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Tests/ColumbaAppTests/NEMemoryTraceTests.swift b/Tests/ColumbaAppTests/NEMemoryTraceTests.swift index d7cc00e0..7f1fa8dd 100644 --- a/Tests/ColumbaAppTests/NEMemoryTraceTests.swift +++ b/Tests/ColumbaAppTests/NEMemoryTraceTests.swift @@ -96,12 +96,21 @@ final class NEMemoryTraceTests: XCTestCase { 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) - legacy.writeLE(NEMemoryTrace.magic, at: NEMemoryTrace.offMagic, width: 4) - legacy.writeLE(1, at: NEMemoryTrace.offVersion, width: 2) - legacy.writeLE(UInt64(v1RecordSize), at: NEMemoryTrace.offRecordSize, width: 2) - legacy.writeLE(UInt64(NEMemoryTrace.ringCapacity), at: NEMemoryTrace.offCapacity, width: 4) + 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]) From f04979ee0ce967745274bb0394d2dfe41f3c63f7 Mon Sep 17 00:00:00 2001 From: torlando-tech <239676438+torlando-tech@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:48:44 +0000 Subject: [PATCH 4/7] Review fixes: lifetime disk total, failed-probe sentinel, rate chart Greptile 5/5 review surfaced three real defects, all fixed: - Kill-card disk total: the counter is cumulative since process start, so the total is the LAST reading outright. The previous last-minus-first subtraction dropped a run's early writes once the ring overwrote its boot marker (>5.6 days). - A failed rusage probe now stores the diskWrittenUnavailable sentinel, never 0 - a zero between real readings would fake a counter reset and inflate the next interval's rate. The analyzer renders it as no data and does not bridge the gap. - The disk card plots the write RATE (KB/s) against the daily budget expressed as a rate (~0.14 KB/s) instead of cumulative-vs-fixed-line: a healthy multi-day run crossed a fixed daily line and read as a breach. --- .../ColumbaApp/Services/NEMemoryHistory.swift | 41 +++++++++------- .../Views/Settings/NEMemoryView.swift | 29 +++++++----- Sources/Shared/NEMemoryTrace.swift | 26 ++++++---- .../ColumbaAppTests/NEMemoryTraceTests.swift | 47 +++++++++++++++++-- 4 files changed, 103 insertions(+), 40 deletions(-) diff --git a/Sources/ColumbaApp/Services/NEMemoryHistory.swift b/Sources/ColumbaApp/Services/NEMemoryHistory.swift index 1efc29eb..9521d80e 100644 --- a/Sources/ColumbaApp/Services/NEMemoryHistory.swift +++ b/Sources/ColumbaApp/Services/NEMemoryHistory.swift @@ -42,8 +42,9 @@ enum NEMemoryHistory { /// 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. - let diskWrittenMB: Double + /// (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 @@ -239,14 +240,14 @@ enum NEMemoryHistory { eventId += 1 } - // Per-generation disk total: `last − first` of the cumulative - // counter. `first.diskWritten == 0` also covers a pre-v2 ring, where - // no record ever carries the counter. - let diskTotalMB: Double? = { - let firstDisk = generation[0].diskWritten - guard last.diskWritten > firstDisk else { return nil } - return megabytes(last.diskWritten - firstDisk) - }() + // 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": @@ -311,18 +312,26 @@ enum NEMemoryHistory { // every boot and teardown instead of a window maximum. guard record.kind == .bucket || record.kind == .live else { continue } var diskRateKBs: Double? - 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 + 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 } - previousPlotted = (date, record.diskWritten) points.append(Point( id: pointId, date: date, footprintMB: megabytes(record.footprint), windowMaxMB: megabytes(record.windowMax), - diskWrittenMB: megabytes(record.diskWritten), + diskWrittenMB: record.diskWritten == NEMemoryTrace.diskWrittenUnavailable + ? nil : megabytes(record.diskWritten), diskRateKBs: diskRateKBs, segment: segment, pid: record.pid )) diff --git a/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift b/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift index 28e23953..d4b35aca 100644 --- a/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift +++ b/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift @@ -270,11 +270,13 @@ struct NEMemoryView: View { } } - /// Cumulative disk writes vs the daily budget the OS enforces. The y-value - /// is a running total (resets at each process restart, hence one line per - /// segment), so the slope IS the write rate — a flat line means nothing is - /// being written, and crossing the dashed line is what gets the process - /// throttled, then killed. + /// 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) { @@ -283,22 +285,22 @@ struct NEMemoryView: View { ForEach(visiblePoints) { point in LineMark( x: .value("Time", point.date), - y: .value("Written", point.diskWrittenMB), + y: .value("Rate", point.diskRateKBs), series: .value("Run", "\(point.segment)") ) .foregroundStyle(Theme.accentColor) .interpolationMethod(.linear) } - RuleMark(y: .value("Daily budget", diskBudgetMB)) + RuleMark(y: .value("Budget rate", diskBudgetKBs)) .foregroundStyle(Color.orange) .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) .annotation(position: .top, alignment: .leading) { - Text("daily budget") + Text("daily average") .font(.caption2) .foregroundStyle(Color.orange) } } - .chartYAxisLabel("MB", position: .leading) + .chartYAxisLabel("KB/s", position: .leading) .chartXAxis { AxisMarks(values: .automatic(desiredCount: 4)) { _ in AxisGridLine() @@ -308,7 +310,7 @@ struct NEMemoryView: View { .frame(height: 140) .accessibilityIdentifier("ne_disk_chart") - Text("Cumulative bytes the background transport has written to disk since it started — one line per process run. The dashed line is the per-day write budget iOS enforces; breaching it throttles the process, and continued breaches kill it.") + 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) } @@ -357,9 +359,10 @@ struct NEMemoryView: View { return limit > current.footprintMB + 1 ? limit : nil } - /// The daily disk-write budget the OS enforces on this process class, in MB. - private var diskBudgetMB: Double { - Double(NEMemoryHistory.dailyDiskWriteBudgetBytes) / 1_048_576 + /// 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 { diff --git a/Sources/Shared/NEMemoryTrace.swift b/Sources/Shared/NEMemoryTrace.swift index b06d815f..46e51902 100644 --- a/Sources/Shared/NEMemoryTrace.swift +++ b/Sources/Shared/NEMemoryTrace.swift @@ -96,6 +96,9 @@ public enum NEMemoryTrace { static let offLifetimePeak = 40 // u64 static let offLimitRemaining = 48 // u64 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) @@ -317,7 +320,9 @@ public enum NEMemoryTrace { guard isRunning(generation: generation) else { return } let snap = MemoryFootprint.sample() - let disk = DiskIO.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 @@ -335,7 +340,7 @@ public enum NEMemoryTrace { footprint: snap.footprint, windowMax: windowMax, lifetimePeak: snap.lifetimePeak, limitRemaining: snap.limitRemaining, - diskWritten: disk.writtenBytes, + diskWritten: diskWritten, pid: pid )) lastLiveUptimeMs = now.uptimeMs @@ -348,7 +353,7 @@ public enum NEMemoryTrace { footprint: snap.footprint, windowMax: windowMax, lifetimePeak: snap.lifetimePeak, limitRemaining: snap.limitRemaining, - diskWritten: disk.writtenBytes, + diskWritten: diskWritten, pid: pid ), header: &header) writeHeader(header) @@ -418,7 +423,7 @@ public enum NEMemoryTrace { private static func appendMarkerLocked(kind: Kind, reason: UInt32) { let snap = MemoryFootprint.sample() - let disk = DiskIO.sample() + let diskWritten = DiskIO.sample()?.writtenBytes ?? diskWrittenUnavailable let now = Clocks.now() var header = readHeader() if kind == .boot { header.bootCount &+= 1 } @@ -428,7 +433,7 @@ public enum NEMemoryTrace { footprint: snap.footprint, windowMax: snap.lifetimePeak, lifetimePeak: snap.lifetimePeak, limitRemaining: snap.limitRemaining, - diskWritten: disk.writtenBytes, + diskWritten: diskWritten, pid: UInt32(getpid()) ), header: &header) writeHeader(header) @@ -758,13 +763,18 @@ enum DiskIO { return unsafeBitCast(sym, to: ProcPidRusageFn.self) }() - static func sample() -> Snapshot { - guard let fn = procPidRusage else { return Snapshot() } + /// 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 { 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 { return Snapshot() } + guard result == 0 else { return nil } return Snapshot(writtenBytes: info.ri_diskio_byteswritten, logicalWrites: info.ri_logical_writes) } } diff --git a/Tests/ColumbaAppTests/NEMemoryTraceTests.swift b/Tests/ColumbaAppTests/NEMemoryTraceTests.swift index 7f1fa8dd..4f9fd404 100644 --- a/Tests/ColumbaAppTests/NEMemoryTraceTests.swift +++ b/Tests/ColumbaAppTests/NEMemoryTraceTests.swift @@ -194,9 +194,10 @@ 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") - // Cumulative counter, so the generation total is last − first (the boot - // marker's 100 KB is part of what that process wrote). - XCTAssertEqual(kill.diskWrittenMB ?? -1, 600.0 * 1024 / 1_048_576, accuracy: 0.001) + // 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") @@ -251,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() { From ec4a0832799d9a55f3c363332ebf74e5615427b6 Mon Sep 17 00:00:00 2001 From: torlando-tech <239676438+torlando-tech@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:50:01 +0000 Subject: [PATCH 5/7] Chart: skip nil-rate points instead of forcing a zero --- .../Views/Settings/NEMemoryView.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift b/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift index d4b35aca..cda2acf7 100644 --- a/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift +++ b/Sources/ColumbaApp/Views/Settings/NEMemoryView.swift @@ -283,13 +283,17 @@ struct NEMemoryView: View { Text("Disk writes").font(.headline) Chart { ForEach(visiblePoints) { point in - LineMark( - x: .value("Time", point.date), - y: .value("Rate", point.diskRateKBs), - series: .value("Run", "\(point.segment)") - ) - .foregroundStyle(Theme.accentColor) - .interpolationMethod(.linear) + // 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) From c04dd0c046da6ebc0b535223fb2b10d855bab6fa Mon Sep 17 00:00:00 2001 From: torlando-tech <239676438+torlando-tech@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:00:33 +0000 Subject: [PATCH 6/7] DiskIO: log probe failure diagnosis once via ext-diag.log The first on-device run stored the sentinel on every record: the probe fails on the real device while succeeding on the sim. Diagnose which leg fails (symbol lookup vs syscall result + errno) with a single ext-diag.log line. --- Sources/Shared/NEMemoryTrace.swift | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Sources/Shared/NEMemoryTrace.swift b/Sources/Shared/NEMemoryTrace.swift index 46e51902..b048de81 100644 --- a/Sources/Shared/NEMemoryTrace.swift +++ b/Sources/Shared/NEMemoryTrace.swift @@ -769,14 +769,30 @@ enum DiskIO { /// 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 { return nil } + 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 { return nil } + 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 From 5ebff91111016565fd05f519e0e3043efaf2cd12 Mon Sep 17 00:00:00 2001 From: torlando-tech <239676438+torlando-tech@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:04:36 +0000 Subject: [PATCH 7/7] DiskIO: Darwin's RTLD_DEFAULT is (void*)-2, not NULL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dlsym(NULL, …) fails on iOS (NULL is not RTLD_DEFAULT there — that is a glibc-ism), which is why the first on-device run stored the sentinel on every record while the sim worked. Pass RTLD_DEFAULT properly. --- Sources/Shared/NEMemoryTrace.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Shared/NEMemoryTrace.swift b/Sources/Shared/NEMemoryTrace.swift index b048de81..c7489fc5 100644 --- a/Sources/Shared/NEMemoryTrace.swift +++ b/Sources/Shared/NEMemoryTrace.swift @@ -755,9 +755,10 @@ enum DiskIO { private typealias ProcPidRusageFn = @convention(c) (Int32, Int32, UnsafeMutableRawPointer) -> Int32 private static let procPidRusage: ProcPidRusageFn? = { - // NULL handle = RTLD_DEFAULT: search the global symbol scope, where - // libsystem_kernel/libproc are always present. - guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: 0), "proc_pid_rusage") else { + // 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)