-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathSystemMonitor.swift
More file actions
352 lines (320 loc) · 14.9 KB
/
SystemMonitor.swift
File metadata and controls
352 lines (320 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
import Foundation
import os.log
import Darwin
/// System monitor for CPU and memory using Mach kernel APIs.
///
/// # CPU: Tick-Delta Measurement
///
/// The kernel tracks cumulative clock ticks spent in each state:
///
/// time ──────────────────────────────────────────────>
/// ticks: [user][sys][idle][idle][user][sys][nice][idle]
/// ▲ ▲
/// sample A sample B
///
/// Each call to `HOST_CPU_LOAD_INFO` returns the running totals.
/// We store two consecutive samples and compute the delta:
///
/// usage = (userD + sysD + niceD) / (userD + sysD + niceD + idleD) * 100
///
/// This gives *instantaneous* CPU usage over the polling window — the same
/// technique Activity Monitor and the Stats app use. The old approach used
/// `HOST_LOAD_INFO` (Unix load average), which is a 1-minute trailing
/// exponential decay that includes I/O-blocked processes, so it could show
/// 100% while Activity Monitor showed 30%.
///
/// ## Counter Rollover
///
/// Kernel tick counters are `natural_t` (UInt32). They wrap every ~49 days
/// at 1000 Hz. Delta math *must* happen at UInt32 width so wrapping
/// subtraction produces the correct small positive result:
///
/// UInt32: 5 &- 0xFFFF_FFFE = 7 (correct)
/// UInt64: 5 - 4294967294 = -4294967289 (garbage)
///
/// After the subtraction, we widen to UInt64 for the summation+division.
///
/// First call returns 0% (no previous sample). Real data arrives after one
/// polling interval (3 seconds).
///
/// # Memory: XNU Page Hierarchy
///
/// XNU partitions physical RAM into page categories:
///
/// +-------------------------------------------------------+
/// | Physical RAM |
/// +-------------------------------------------------------+
/// | Wired | Internal | External | Free |
/// | (kern)| (apps) | (file cache)| +------------------+ |
/// | | +------+ | | | speculative (sub- | |
/// | | |purgea| | | | set of free, NOT | |
/// | | |ble | | | | additive) | |
/// | | +------+ | | +------------------+ |
/// +-------------------------------------------------------+
///
/// Key insight: `vm_statistics.speculative_count` is already *included*
/// in `free_count`. Adding both double-counts free memory and understates
/// pressure. The corrected formula:
///
/// available = free + inactive + external (no speculative)
///
/// We prefer the kernel's own pressure signal (`kern.memorystatus_vm_pressure_level`)
/// and only fall back to the ratio heuristic if the sysctl fails.
///
/// # Thread Safety
///
/// `SystemMonitor` is `@unchecked Sendable`. `getCPUUsage()` mutates
/// `previousCPUTicks` from a background task, so the read-write is guarded
/// by `cpuLock`. `getMemoryInfo()` is stateless and needs no lock.
public final class SystemMonitor: @unchecked Sendable {
private let logger = Logger(subsystem: "com.microverse.app", category: "SystemMonitor")
/// A snapshot of the kernel's cumulative CPU tick counters.
/// Keep this as UInt32 because kernel `natural_t` ticks are UInt32 and
/// wrap at 32 bits. Delta math relies on UInt32 wrapping subtraction.
private struct CPUTickSample {
let user: UInt32
let system: UInt32
let idle: UInt32
let nice: UInt32
}
/// Previous tick snapshot for delta computation. `nil` until first call.
private var previousCPUTicks: CPUTickSample?
/// Guards `previousCPUTicks` — getCPUUsage() runs off the main actor.
private let cpuLock = NSLock()
public init() {}
/// Clears the CPU baseline so the next `getCPUUsage()` call primes a fresh sample.
public func resetCPUUsageSampling() {
cpuLock.lock()
previousCPUTicks = nil
cpuLock.unlock()
}
/// Instantaneous CPU usage (0--100%) via tick deltas.
///
/// Normal case:
///
/// poll N poll N+1
/// | |
/// v v
/// [user=1200] [user=1500] -> userD = 300
/// [sys = 80] [sys = 100] -> sysD = 20
/// [idle=8000] [idle=8350] -> idleD = 350
/// [nice= 20] [nice= 50] -> niceD = 30
/// ---- ----
/// used = 350
/// total = 700
/// usage = 50%
///
/// UInt32 rollover case (~49 days uptime at 1000 Hz):
///
/// prev.idle = 0xFFFF_FF00 current.idle = 0x0000_0032
/// UInt32: 0x0000_0032 &- 0xFFFF_FF00 = 0x132 (306) <-- correct
/// UInt64: 50 - 4294967040 = negative <-- wrong
///
/// Deltas are computed at UInt32 width, then widened for summation.
///
/// Returns 0.0 on the very first call (no previous sample yet).
public func getCPUUsage() -> Double {
// --- 1. Read cumulative tick counters from the kernel ---------------
var loadInfo = host_cpu_load_info_data_t()
var count = mach_msg_type_number_t(
MemoryLayout<host_cpu_load_info_data_t>.size / MemoryLayout<integer_t>.size
)
let result = withUnsafeMutablePointer(to: &loadInfo) { ptr in
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, intPtr, &count)
}
}
guard result == KERN_SUCCESS else {
logger.error("Failed to get CPU ticks: \(result)")
return 0.0
}
// cpu_ticks layout: (.0=user, .1=system, .2=idle, .3=nice)
// Keep raw UInt32 values to preserve correct 32-bit wrap behavior.
let current = CPUTickSample(
user: UInt32(loadInfo.cpu_ticks.0),
system: UInt32(loadInfo.cpu_ticks.1),
idle: UInt32(loadInfo.cpu_ticks.2),
nice: UInt32(loadInfo.cpu_ticks.3)
)
// --- 2. Swap previous <-> current under lock -----------------------
cpuLock.lock()
let previous = previousCPUTicks
previousCPUTicks = current
cpuLock.unlock()
guard let previous else {
return 0.0 // first sample — no delta yet
}
// --- 3. Compute deltas ---------------------------------------------
// Two-phase arithmetic:
// a) &- at UInt32 width -> correct wrap on rollover
// b) widen to UInt64 -> safe summation without overflow
let userDelta = UInt64(current.user &- previous.user)
let systemDelta = UInt64(current.system &- previous.system)
let idleDelta = UInt64(current.idle &- previous.idle)
let niceDelta = UInt64(current.nice &- previous.nice)
// --- 4. Derive percentage ------------------------------------------
// used = user + system + nice (all non-idle work)
// total = used + idle
let used = userDelta &+ systemDelta &+ niceDelta
let total = used &+ idleDelta
guard total > 0 else { return 0.0 }
let cpuUsage = (Double(used) / Double(total)) * 100.0
let clamped = min(100.0, max(0.0, cpuUsage))
logger.debug("CPU usage: \(String(format: "%.1f", clamped))% (user=\(userDelta) sys=\(systemDelta) idle=\(idleDelta) nice=\(niceDelta))")
return clamped
}
/// Memory usage and pressure level.
///
/// Breakdown (mirrors Activity Monitor's "Memory" tab):
///
/// +--------------------------------------------------+
/// | Used (reported %) | Available |
/// | App + Wired + Compressed | |
/// +--------------------------------------------------+
/// | App = internal - purgeable |
/// | Cached = external + purgeable (shown separately)|
/// +--------------------------------------------------+
///
/// Pressure comes from `kern.memorystatus_vm_pressure_level`, the same
/// signal that drives macOS's own memory pressure graph. The sysctl
/// returns an XNU constant (1=normal, 2=warn, 4=critical). If the
/// sysctl fails, we fall back to:
///
/// available = free + inactive + external
/// ratio = available / total
/// <0.05 => critical | <0.15 => warning | else => normal
///
public func getMemoryInfo() -> MemoryInfo {
var info = vm_statistics64()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.size / MemoryLayout<natural_t>.size)
let result = withUnsafeMutablePointer(to: &info) { ptr in
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in
host_statistics64(mach_host_self(), HOST_VM_INFO64, intPtr, &count)
}
}
guard result == KERN_SUCCESS else {
logger.error("Failed to get memory statistics: \(result)")
return MemoryInfo()
}
// Get physical memory size
var physicalMemory: UInt64 = 0
var size = MemoryLayout<UInt64>.size
sysctlbyname("hw.memsize", &physicalMemory, &size, nil, 0)
// Avoid referencing the global `vm_page_size` var directly (not concurrency-safe in Swift 6).
var pageSizeBytes: UInt64 = 4096
var sysctlPageSize: Int = 0
var sysctlPageSizeSize = MemoryLayout<Int>.size
if sysctlbyname("hw.pagesize", &sysctlPageSize, &sysctlPageSizeSize, nil, 0) == 0, sysctlPageSize > 0 {
pageSizeBytes = UInt64(sysctlPageSize)
}
// Calculate memory metrics
let totalMemory = Double(physicalMemory) / (1024 * 1024 * 1024) // GB
let freePages = UInt64(info.free_count)
let inactivePages = UInt64(info.inactive_count)
let wiredPages = UInt64(info.wire_count)
let compressedPages = UInt64(info.compressor_page_count)
let purgeablePages = UInt64(info.purgeable_count)
let externalPages = UInt64(info.external_page_count)
let internalPages = UInt64(info.internal_page_count)
// Calculate memory used like Activity Monitor:
// App Memory = internal_page_count - purgeable_count (these are anonymous/app pages)
// Wired Memory = wire_count (kernel memory that can't be swapped)
// Compressed = compressor_page_count (compressed memory pages)
// Memory Used = App Memory + Wired + Compressed
let appMemoryPages = internalPages > purgeablePages ? internalPages - purgeablePages : 0
let usedMemory = Double((appMemoryPages + wiredPages + compressedPages) * pageSizeBytes) / (1024 * 1024 * 1024) // GB
// Calculate cached files (file-backed memory that can be freed)
// Cached = external_page_count + purgeable_count
let cachedMemory = Double((externalPages + purgeablePages) * pageSizeBytes) / (1024 * 1024 * 1024) // GB
// --- Pressure: prefer the kernel's own level, fall back to ratio ---
//
// Primary: kern.memorystatus_vm_pressure_level (same source as
// the system memory pressure graph in Activity Monitor)
//
// Fallback: ratio = (free + inactive + external) / total
// Note: speculative is *inside* free — adding it would
// double-count and understate pressure.
//
// XNU vm_statistics.h (simplified):
// free_count
// +-- speculative_count <-- subset, NOT additive
//
let pressure: MemoryPressure
if let kernelPressure = kernelMemoryPressureLevel() {
pressure = kernelPressure
} else {
let availablePages = freePages + inactivePages + externalPages
let totalPages = UInt64(physicalMemory / pageSizeBytes)
let pressureRatio = Double(availablePages) / Double(totalPages)
if pressureRatio < 0.05 {
pressure = .critical
} else if pressureRatio < 0.15 {
pressure = .warning
} else {
pressure = .normal
}
}
let memoryString = String(format: "Memory: %.1f/%.1fGB, cached: %.1fGB, pressure: %@", usedMemory, totalMemory, cachedMemory, pressure.rawValue)
logger.debug("\(memoryString)")
return MemoryInfo(
totalMemory: totalMemory,
usedMemory: usedMemory,
cachedMemory: cachedMemory,
pressure: pressure,
compressionRatio: Double(compressedPages) / Double(max(1, appMemoryPages + wiredPages + compressedPages))
)
}
/// Ask the kernel for its memory pressure verdict.
///
/// sysctl kern.memorystatus_vm_pressure_level
/// -> 1 = normal (plenty of free/reclaimable pages)
/// -> 2 = warning (system is compressing / swapping)
/// -> 4 = critical (OOM killer may start terminating apps)
///
/// Available since macOS 10.9. Works inside the app sandbox.
/// Returns `nil` if the sysctl call fails (graceful fallback path).
private func kernelMemoryPressureLevel() -> MemoryPressure? {
var level: Int32 = 0
var size = MemoryLayout<Int32>.size
guard sysctlbyname("kern.memorystatus_vm_pressure_level", &level, &size, nil, 0) == 0 else {
return nil
}
switch level {
case 4: return .critical
case 2: return .warning
default: return .normal
}
}
}
/// Memory pressure levels matching macOS
public enum MemoryPressure: String, CaseIterable, Sendable {
case normal = "Normal"
case warning = "Warning"
case critical = "Critical"
public var color: String {
switch self {
case .normal: return "green"
case .warning: return "orange"
case .critical: return "red"
}
}
}
/// System memory information
public struct MemoryInfo: Sendable, Equatable {
public let totalMemory: Double // GB
public let usedMemory: Double // GB
public let cachedMemory: Double // GB
public let pressure: MemoryPressure
public let compressionRatio: Double // 0-1
public init(totalMemory: Double = 0, usedMemory: Double = 0, cachedMemory: Double = 0, pressure: MemoryPressure = .normal, compressionRatio: Double = 0) {
self.totalMemory = totalMemory
self.usedMemory = usedMemory
self.cachedMemory = cachedMemory
self.pressure = pressure
self.compressionRatio = compressionRatio
}
public var usagePercentage: Double {
guard totalMemory > 0 else { return 0 }
return (usedMemory / totalMemory) * 100.0
}
}