Skip to content
Merged
60 changes: 57 additions & 3 deletions Sources/ColumbaApp/Services/NEMemoryHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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.
Expand Down Expand 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.
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions Sources/ColumbaApp/Views/Settings/NEMemoryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ struct NEMemoryView: View {
statsRow
rangePicker
chartCard
diskChartCard
explainer
}
.padding(20)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading