gander_core/system.rs
1//! System-wide snapshots: CPU, memory, swap, load, uptime.
2//!
3//! One immutable value per sample, handed to the UI atomically (`docs/DESIGN.md` §6).
4
5use std::time::Instant;
6
7#[derive(Copy, Clone, Default, Debug)]
8pub struct CpuTimes {
9 pub user: u64,
10 pub nice: u64,
11 pub system: u64,
12 pub idle: u64,
13 pub iowait: u64,
14 pub irq: u64,
15 pub softirq: u64,
16 pub steal: u64,
17 pub guest: u64,
18 pub guest_nice: u64,
19}
20
21impl CpuTimes {
22 /// Total non-idle-plus-idle ticks. This is the denominator for process CPU%.
23 pub fn total(&self) -> u64 {
24 self.user
25 + self.nice
26 + self.system
27 + self.idle
28 + self.iowait
29 + self.irq
30 + self.softirq
31 + self.steal
32 + self.guest
33 + self.guest_nice
34 }
35
36 /// Everything that is not idle. Split into the segments htop's CPU bar draws.
37 pub fn busy(&self) -> u64 {
38 self.total().saturating_sub(self.idle)
39 }
40
41 /// Tick-wise difference against an earlier sample.
42 pub fn delta(&self, prev: &CpuTimes) -> CpuTimes {
43 CpuTimes {
44 user: self.user.saturating_sub(prev.user),
45 nice: self.nice.saturating_sub(prev.nice),
46 system: self.system.saturating_sub(prev.system),
47 idle: self.idle.saturating_sub(prev.idle),
48 iowait: self.iowait.saturating_sub(prev.iowait),
49 irq: self.irq.saturating_sub(prev.irq),
50 softirq: self.softirq.saturating_sub(prev.softirq),
51 steal: self.steal.saturating_sub(prev.steal),
52 guest: self.guest.saturating_sub(prev.guest),
53 guest_nice: self.guest_nice.saturating_sub(prev.guest_nice),
54 }
55 }
56}
57
58/// One named class of memory, as htop models it.
59///
60/// `counts_as_used` adds it to the "used" figure; `counts_as_cache` marks it as
61/// reclaimable. Modeling this as data (which is what htop 3.6 does in
62/// `MemoryClass[]`) keeps the formula reviewable instead of hiding it in
63/// arithmetic.
64#[derive(Copy, Clone, Debug)]
65pub struct MemoryClass {
66 pub label: &'static str,
67 pub bytes: u64,
68 pub counts_as_used: bool,
69 pub counts_as_cache: bool,
70 pub counts_as_total: bool,
71 /// Color slot, 0-5, matching htop's `MEMORY_1..6` palette order.
72 pub color_slot: u8,
73}
74
75/// The memory breakdown a platform reports.
76///
77/// Every backend fills in `total` and `classes`, then calls
78/// `finish_linux` or `finish_darwin` exactly once to derive `used`/`available`.
79///
80/// The classes are the *authority* on what counts as used: `finish_*` only sums
81/// the flags. This matters because the platforms genuinely disagree, and every
82/// attempt to hardcode a formula produced a number that contradicted either the
83/// class sum or the operating system (`docs/PORTABILITY.md`).
84#[derive(Clone, Default, Debug)]
85pub struct MemInfo {
86 pub total: u64,
87 /// Memory that can be handed to a new allocation, including reclaimable
88 /// cache. Derived by the `finish_*` methods.
89 pub available: u64,
90 /// Derived: the sum of every class flagged `counts_as_used`.
91 pub used: u64,
92 /// Free pages. Not a class of its own on macOS, where `free` is tiny and the
93 /// page cache holds the slack.
94 pub free: u64,
95 /// The ordered class list. Order is the bar-segment order.
96 pub classes: Vec<MemoryClass>,
97
98 // Raw fields kept for platform-specific reporting and diagnostics.
99 pub buffers: u64,
100 pub cached: u64,
101 pub sreclaimable: u64,
102 pub shmem: u64,
103 pub wired: u64,
104 pub compressed: u64,
105 pub active: u64,
106 pub inactive: u64,
107 pub purgeable: u64,
108 pub speculative: u64,
109
110 // --- M3: zswap and huge pages -----------------------------------------
111 /// Bytes currently held in the zswap pool (`Meminfo: Zswap`).
112 ///
113 /// Not part of the memory *bar*: zswap pages are anonymous pages that have
114 /// been compressed, so they are already counted in `used`. Reporting them
115 /// again would double-count. They get their own meter instead.
116 pub zswap: u64,
117 /// Bytes swapped out to zswap over the machine's lifetime (`Zswapped`).
118 pub zswapped: u64,
119 /// The zswap pool's ceiling, from
120 /// `/sys/module/zswap/parameters/max_pool_percent` times physical memory.
121 /// `0` when the parameter is unreadable.
122 pub zswap_max: u64,
123 /// Huge page counters, in *pages* (`Meminfo: HugePages_*`).
124 pub huge_pages_total: u64,
125 pub huge_pages_free: u64,
126 pub huge_pages_rsvd: u64,
127 pub huge_pages_surplus: u64,
128 /// Bytes per huge page (`Hugepagesize`), `0` when huge pages are disabled.
129 pub huge_page_size: u64,
130 // --- FreeBSD -----------------------------------------------------------
131 /// Pages that are dirty and queued for writeback (`v_laundry_count`).
132 ///
133 /// FreeBSD counts these separately from `active` and `inactive`, and they
134 /// cannot be reused without a write first, so they belong in `used` rather
135 /// than in cache.
136 pub laundry: u64,
137 /// The page cache (`v_cache_count`), in bytes rather than pages.
138 ///
139 /// Modern FreeBSD folds the unified buffer cache into the inactive queue and
140 /// reports 0 here; it is kept because older kernels and `vfs.bufspace` still
141 /// populate it, and silently dropping it would understate reclaimable memory
142 /// on exactly those systems.
143 pub file_cache: u64,
144 /// `v_page_count`, the number of pages the VM subsystem manages.
145 ///
146 /// Used as the denominator for the partition check: `active + inactive +
147 /// laundry + wired + free` must equal it, which is what makes the bar
148 /// segments add up to physical memory.
149 pub managed_pages: u64,
150
151 /// `internal_page_count`: anonymous pages, the "by backing store" family.
152 ///
153 /// macOS reports memory twice over, once by usage (`active`, `inactive`, …)
154 /// and once by backing store (`internal`, `external`). Only this one is
155 /// needed for accounting, because `internal` is exactly
156 /// `active + inactive + speculative - external` and therefore counts each
157 /// anonymous page once instead of re-adding overlapping usage classes.
158 pub internal: u64,
159}
160
161impl MemInfo {
162 /// Sum the classes tagged `counts_as_used`.
163 pub fn used_from_classes(&self) -> u64 {
164 self.classes
165 .iter()
166 .filter(|c| c.counts_as_used)
167 .map(|c| c.bytes)
168 .sum()
169 }
170
171 /// Sum the classes tagged `counts_as_cache`.
172 pub fn cache_from_classes(&self) -> u64 {
173 self.classes
174 .iter()
175 .filter(|c| c.counts_as_cache)
176 .map(|c| c.bytes)
177 .sum()
178 }
179
180 /// Linux/GNU `free` accounting, matching `linux/LinuxMachine.c`.
181 ///
182 /// `used = total - free - cached - sreclaimable - buffers`, with `shmem`
183 /// subtracted from the cached total first so it is not counted twice.
184 /// `available` is `MemAvailable` clamped to `total`, falling back to `free`
185 /// on kernels older than 3.14 that do not provide it.
186 ///
187 /// Do not try to make `used + available == total` here: they measure
188 /// different things, and forcing it clamps `MemAvailable` down to
189 /// `total - used`, which is a much smaller (wrong) number.
190 pub fn finish_linux(&mut self) {
191 // `used = total - free - cached - sreclaimable - buffers`, which is
192 // exactly `free(1)`'s `total - free - buff/cache`.
193 let used_diff = self.free + self.cached + self.sreclaimable + self.buffers;
194 self.used = if self.total >= used_diff {
195 self.total - used_diff
196 } else {
197 self.total.saturating_sub(self.free)
198 };
199 // Report `cached` the way `free` does: Meminfo's `Cached` plus
200 // `SReclaimable`, minus `Shmem` which is already counted in `Cached`.
201 // Overwriting the field keeps one meaning from here on; computing `used`
202 // above used the raw values.
203 self.cached = self.cached + self.sreclaimable - self.shmem;
204 self.available = if self.available != 0 {
205 self.available.min(self.total)
206 } else {
207 self.free
208 };
209
210 self.classes = vec![
211 MemoryClass {
212 label: "used",
213 bytes: self.used,
214 counts_as_used: true,
215 counts_as_cache: false,
216 counts_as_total: true,
217 color_slot: 0,
218 },
219 MemoryClass {
220 label: "buffers",
221 bytes: self.buffers,
222 counts_as_used: false,
223 counts_as_cache: true,
224 counts_as_total: true,
225 color_slot: 1,
226 },
227 MemoryClass {
228 label: "cache",
229 bytes: self.cached,
230 counts_as_used: false,
231 counts_as_cache: true,
232 counts_as_total: true,
233 color_slot: 2,
234 },
235 MemoryClass {
236 label: "free",
237 bytes: self.free,
238 counts_as_used: false,
239 counts_as_cache: false,
240 counts_as_total: true,
241 color_slot: 5,
242 },
243 ];
244 }
245
246 /// macOS accounting, matching **htop 3.4 and later**.
247 ///
248 /// ```text
249 /// used = (internal - purgeable) + wired + compressed
250 /// ```
251 ///
252 /// htop 3.4.1 (`darwin/Platform.c`, `Platform_setMemoryValues`) says it the
253 /// long way round:
254 ///
255 /// ```c
256 /// natural_t used = vm->active_count + vm->inactive_count +
257 /// vm->speculative_count + vm->wire_count +
258 /// vm->compressor_page_count - vm->purgeable_count -
259 /// vm->external_page_count;
260 /// mtr->values[MEMORY_METER_USED] = (double)(used - vm->compressor_page_count) * page_K;
261 /// mtr->values[MEMORY_METER_COMPRESSED] = (double)vm->compressor_page_count * page_K;
262 /// ```
263 ///
264 /// and `MemoryMeter_updateValues` prints `USED + COMPRESSED`. That sum is
265 /// `internal + wired + compressed`, because `internal_page_count` *is*
266 /// `active + inactive + speculative - external`. htop `main` (3.6-dev) states
267 /// the same figure directly, as a class list marked `countsAsUsed`: `wired`,
268 /// `internal - purgeable`, `compressed`. One formula therefore matches both,
269 /// and it matches Activity Monitor and `top` as well.
270 ///
271 /// Consequences, all intentional:
272 /// * The compressor **is** counted as used. On a Mac that has been running a
273 /// while it is the largest single term — 7 GB on a 16 GB machine is normal
274 /// — and leaving it out made gander report ~5 GB where htop reported ~13 GB.
275 /// * `inactive` and `speculative` are **not** added on top. They are part of
276 /// `internal` (or are file cache), so adding them double-counts; that was
277 /// the other shipped mistake, and it is why the by-backing-store counter is
278 /// used here instead of the by-usage ones.
279 /// * `free` is `vm_stat`'s own free count. Deriving it as `total - used -
280 /// cache` is what made the bar claim 7.8 GB free on a machine with 0.3 GB
281 /// free: the compressor pages that `used` omitted fell through into the
282 /// free segment.
283 /// * `available = total - used`, so the two figures partition memory exactly,
284 /// and so do the bar segments.
285 ///
286 /// The old htop-3.3.0 formula (`used = active + wired`, compressor ignored)
287 /// is gone: it was the reason the meter disagreed with every other tool by
288 /// the size of the compressor.
289 pub fn finish_darwin(&mut self) {
290 // Clamp every class against what is left of physical memory, so a kernel
291 // that reports overlapping or absurd counters cannot overflow the bar.
292 let wired = self.wired.min(self.total);
293 let compressed = self.compressed.min(self.total - wired);
294 // Anonymous pages, minus the ones the OS may discard on demand.
295 let internal = self.internal.min(self.total - wired - compressed);
296 let purgeable = self.purgeable.min(internal);
297 // htop's "active" is the anonymous figure net of purgeable pages; that is
298 // exactly what `MEMORY_METER_USED` reports on 3.4, and what htop `main`
299 // calls the `active` class.
300 let active = internal - purgeable;
301
302 self.used = wired + compressed + active;
303
304 // Free comes from the kernel, not from arithmetic. Everything left over is
305 // reclaimable: file-backed pages (`external`), `speculative`, and the page
306 // categories neither counter describes.
307 let free = self.free.min(self.total - self.used - purgeable);
308 let cache = self.total - self.used - purgeable - free;
309 self.available = self.total - self.used;
310
311 // Order matches htop's `MEMORY_1..6`: hot classes first, then the
312 // reclaimable ones, then free. The segments partition the bar — each page
313 // is counted exactly once, so they add up to `total`.
314 self.classes = vec![
315 MemoryClass {
316 label: "wired",
317 bytes: wired,
318 counts_as_used: true,
319 counts_as_cache: false,
320 counts_as_total: true,
321 color_slot: 0,
322 },
323 MemoryClass {
324 label: "active",
325 bytes: active,
326 counts_as_used: true,
327 counts_as_cache: false,
328 counts_as_total: true,
329 color_slot: 1,
330 },
331 MemoryClass {
332 label: "compressed",
333 bytes: compressed,
334 counts_as_used: true,
335 counts_as_cache: false,
336 counts_as_total: true,
337 color_slot: 2,
338 },
339 MemoryClass {
340 label: "purgeable",
341 bytes: purgeable,
342 counts_as_used: false,
343 counts_as_cache: true,
344 counts_as_total: true,
345 color_slot: 4,
346 },
347 MemoryClass {
348 label: "cache",
349 bytes: cache,
350 counts_as_used: false,
351 counts_as_cache: true,
352 counts_as_total: true,
353 color_slot: 3,
354 },
355 MemoryClass {
356 label: "free",
357 bytes: free,
358 counts_as_used: false,
359 counts_as_cache: false,
360 counts_as_total: true,
361 color_slot: 5,
362 },
363 ];
364 }
365
366 /// FreeBSD accounting.
367 ///
368 /// FreeBSD's VM keeps five mutually exclusive page queues, so the model is a
369 /// partition rather than a formula:
370 ///
371 /// ```text
372 /// used = active + wired + laundry
373 /// cache = inactive + page cache
374 /// available = free + cache
375 /// ```
376 ///
377 /// Every page is in exactly one queue (`v_page_count` is their sum), which is
378 /// why the bar segments can be checked against physical memory the way
379 /// macOS's can. `laundry` counts as used because those pages cannot be
380 /// reused until their write completes — the same reasoning that puts
381 /// macOS's compressor in `used`.
382 ///
383 /// `free` is the kernel's own free count, never `total - used`: deriving it
384 /// is what made the macOS bar claim 7.8 GB free on a machine with 0.3 GB.
385 pub fn finish_freebsd(&mut self) {
386 let wired = self.wired.min(self.total);
387 let active = self.active.min(self.total - wired);
388 let laundry = self.laundry.min(self.total - wired - active);
389 let used = wired + active + laundry;
390
391 let inactive = self.inactive.min(self.total - used);
392 let file_cache = self.file_cache.min(self.total - used - inactive);
393 let free = self.free.min(self.total - used - inactive - file_cache);
394
395 self.used = used;
396 self.cached = inactive + file_cache;
397 self.available = (free + self.cached).min(self.total);
398
399 self.classes = vec![
400 MemoryClass {
401 label: "wired",
402 bytes: wired,
403 counts_as_used: true,
404 counts_as_cache: false,
405 counts_as_total: true,
406 color_slot: 0,
407 },
408 MemoryClass {
409 label: "active",
410 bytes: active,
411 counts_as_used: true,
412 counts_as_cache: false,
413 counts_as_total: true,
414 color_slot: 1,
415 },
416 MemoryClass {
417 label: "laundry",
418 bytes: laundry,
419 counts_as_used: true,
420 counts_as_cache: false,
421 counts_as_total: true,
422 color_slot: 2,
423 },
424 MemoryClass {
425 label: "inactive",
426 bytes: inactive,
427 counts_as_used: false,
428 counts_as_cache: true,
429 counts_as_total: true,
430 color_slot: 3,
431 },
432 MemoryClass {
433 label: "cache",
434 bytes: file_cache,
435 counts_as_used: false,
436 counts_as_cache: true,
437 counts_as_total: true,
438 color_slot: 4,
439 },
440 MemoryClass {
441 label: "free",
442 bytes: free,
443 counts_as_used: false,
444 counts_as_cache: false,
445 counts_as_total: true,
446 color_slot: 5,
447 },
448 ];
449 }
450
451 /// Huge pages in use, in bytes.
452 ///
453 /// `total - free` rather than `total - free - rsvd`: reserved pages are
454 /// already committed to a process that has not faulted them in yet, so a
455 /// `free` figure that includes them overstates what is actually available.
456 /// htop prints `used/total` with the same subtraction.
457 pub fn huge_used_bytes(&self) -> u64 {
458 let used_pages = self.huge_pages_total.saturating_sub(self.huge_pages_free);
459 used_pages.saturating_mul(self.huge_page_size)
460 }
461
462 pub fn huge_total_bytes(&self) -> u64 {
463 self.huge_pages_total.saturating_mul(self.huge_page_size)
464 }
465
466 /// Whether the kernel has huge pages configured at all.
467 ///
468 /// A machine with `HugePages_Total` of 0 has the feature compiled in but
469 /// nothing reserved; a meter for it would be a permanent empty bar, so the
470 /// meter says `n/a here` instead. Same reasoning as `DiskIO` on macOS.
471 pub fn huge_pages_configured(&self) -> bool {
472 self.huge_pages_total > 0 && self.huge_page_size > 0
473 }
474
475 /// Backwards-compatible alias used by the Linux parser.
476 pub fn compute_used(&mut self) {
477 self.finish_linux();
478 }
479
480 /// Whether every derived figure is within physical bounds.
481 pub fn is_consistent(&self) -> bool {
482 self.used <= self.total && self.available <= self.total
483 }
484
485 /// Stronger check for platforms that derive `available` from `used`.
486 pub fn is_exactly_partitioned(&self) -> bool {
487 self.used <= self.total && self.used + self.available == self.total
488 }
489}
490
491/// Process-table counts, so meters can summarise processes without reaching into
492/// the table themselves.
493///
494/// htop prints `Tasks: 472, 1664 thr, 0 kthr; 1 running`.
495#[derive(Copy, Clone, Default, Debug)]
496pub struct TaskStats {
497 /// Processes, excluding additional thread rows.
498 pub total: usize,
499 /// Running tasks; on macOS this is the running thread count, capped at CPUs.
500 pub running: usize,
501 /// Userland threads, including main threads.
502 pub threads: usize,
503 /// Kernel threads reported separately by the backend (always 0 on macOS).
504 pub kernel_threads: usize,
505}
506
507#[derive(Copy, Clone, Default, Debug)]
508pub struct SwapInfo {
509 pub total: u64,
510 pub free: u64,
511 pub used: u64,
512}
513
514/// Host-wide network byte counters, cumulative since boot.
515///
516/// Loopback is excluded by both backends: `lo` traffic never reaches a wire, and
517/// counting it would both inflate the figure and double every byte (once as
518/// received, once as transmitted). A container or a busy local database would
519/// otherwise report hundreds of MB/s of "network" while the NIC is idle.
520#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
521pub struct NetCounters {
522 pub rx_bytes: u64,
523 pub tx_bytes: u64,
524}
525
526impl NetCounters {
527 /// Counter delta against an earlier sample, saturating on reset.
528 ///
529 /// A counter can go *backwards*: an interface is destroyed and recreated, or
530 /// the kernel wraps the counter on 32-bit `if_data`. Reporting a negative
531 /// rate (or a 4 GiB spike) for one tick is worse than reporting zero, so a
532 /// decreasing counter contributes nothing.
533 pub fn delta(&self, prev: &NetCounters) -> NetCounters {
534 NetCounters {
535 rx_bytes: self.rx_bytes.saturating_sub(prev.rx_bytes),
536 tx_bytes: self.tx_bytes.saturating_sub(prev.tx_bytes),
537 }
538 }
539
540 /// Bytes per second between two samples `secs` apart.
541 pub fn per_sec(&self, prev: &NetCounters, secs: f64) -> Rate {
542 let d = self.delta(prev);
543 Rate::from_bytes(d.rx_bytes, d.tx_bytes, secs)
544 }
545}
546
547/// Host-wide disk byte counters, cumulative since boot.
548#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
549pub struct DiskCounters {
550 pub read_bytes: u64,
551 pub write_bytes: u64,
552}
553
554impl DiskCounters {
555 pub fn delta(&self, prev: &DiskCounters) -> DiskCounters {
556 DiskCounters {
557 read_bytes: self.read_bytes.saturating_sub(prev.read_bytes),
558 write_bytes: self.write_bytes.saturating_sub(prev.write_bytes),
559 }
560 }
561
562 pub fn per_sec(&self, prev: &DiskCounters, secs: f64) -> Rate {
563 let d = self.delta(prev);
564 Rate::from_bytes(d.read_bytes, d.write_bytes, secs)
565 }
566}
567
568/// A throughput in bytes per second, split into its two directions.
569///
570/// Kept as `f64` rather than `u64` because a 1.5 s interval divides a small
571/// delta into a fraction; truncating there made a steady 3 B/s read show as
572/// alternating 0 and 6.
573#[derive(Copy, Clone, Default, Debug, PartialEq)]
574pub struct Rate {
575 /// Received (net) or read (disk).
576 pub inbound: f64,
577 /// Transmitted (net) or written (disk).
578 pub outbound: f64,
579}
580
581impl Rate {
582 fn from_bytes(inbound: u64, outbound: u64, secs: f64) -> Self {
583 if secs <= 0.0 || secs.is_nan() {
584 // First sample: there is no interval to divide by. Zero, not NaN, so
585 // a meter never has to special-case its first frame.
586 return Self::default();
587 }
588 Self {
589 inbound: inbound as f64 / secs,
590 outbound: outbound as f64 / secs,
591 }
592 }
593
594 /// Combined throughput, for scaling a single bar or graph.
595 pub fn total(&self) -> f64 {
596 self.inbound + self.outbound
597 }
598}
599
600/// One PSI resource's `some` and `full` averages.
601///
602/// `/proc/pressure/<resource>` reports two lines:
603///
604/// ```text
605/// some avg10=0.00 avg60=0.00 avg300=0.00 total=0
606/// full avg10=0.00 avg60=0.00 avg300=0.00 total=0
607/// ```
608///
609/// `some` is the share of wall time during which *at least one* task was
610/// stalled; `full` is the share during which *every* runnable task was stalled,
611/// i.e. the machine genuinely had nothing to run.
612///
613/// **All four resources report both lines**, `cpu` included. An earlier revision
614/// of this comment claimed `cpu` had no `full` line — that is wrong, and checking
615/// a real `/proc/pressure/cpu` (Linux 6.x) shows both. The two lines mean slightly
616/// different things for `cpu` (a task runnable-but-not-running is `some`; the
617/// whole CPU idle while tasks wait is `full`), which is exactly why the kernel
618/// reports both. Code here must not assume `has_full` is false for any resource.
619#[derive(Copy, Clone, Default, Debug, PartialEq)]
620pub struct PsiLine {
621 /// Percentage of wall time stalled, averaged over 10 s.
622 pub avg10: f32,
623 /// Percentage of wall time stalled, averaged over 60 s.
624 pub avg60: f32,
625 /// Percentage of wall time stalled, averaged over 300 s.
626 pub avg300: f32,
627 /// Total microseconds stalled since boot. Cumulative, so it can be used for
628 /// a rate the same way the network counters are.
629 pub total_us: u64,
630}
631
632/// Pressure stall information for one resource.
633#[derive(Copy, Clone, Default, Debug, PartialEq)]
634pub struct PsiResource {
635 /// Readable at all. `false` means the file was missing.
636 pub present: bool,
637 /// The `some` line.
638 pub some: PsiLine,
639 /// The `full` line. Left at zero when the file has none.
640 pub full: PsiLine,
641 /// Whether this resource's file carried a `full` line.
642 ///
643 /// True for every resource on current kernels, `cpu` included. Kept because
644 /// it is the file that decides, not us: a future kernel is free to drop a
645 /// line, and a meter that assumed one was present would draw a fabricated
646 /// zero instead of leaving the value out.
647 pub has_full: bool,
648}
649
650/// Host pressure stall information.
651///
652/// # Why this exists on top of CPU% and load average
653///
654/// Neither of those answers "is the machine actually struggling". A box at 90 %
655/// CPU with nothing waiting is busy but healthy; a box at 30 % CPU with an `io`
656/// `full` average of 40 % is making every process wait on the disk, and both
657/// CPU% and load average look unremarkable while it happens. PSI is the kernel's
658/// own answer to "how much time did work actually lose", and it is the one
659/// number that distinguishes the two cases.
660#[derive(Copy, Clone, Default, Debug, PartialEq)]
661pub struct Psi {
662 pub cpu: PsiResource,
663 pub memory: PsiResource,
664 pub io: PsiResource,
665 pub irq: PsiResource,
666}
667
668impl Psi {
669 /// Whether any resource was readable. A `Psi` where nothing is present is
670 /// worse than `None`: it draws three empty bars that look like an idle
671 /// machine instead of an absent feature.
672 pub fn any_present(&self) -> bool {
673 self.cpu.present || self.memory.present || self.io.present || self.irq.present
674 }
675
676 /// The resources in display order, with their labels.
677 pub fn rows(&self) -> [(&'static str, PsiResource); 4] {
678 [
679 ("CPU", self.cpu),
680 ("MEM", self.memory),
681 ("IO", self.io),
682 ("IRQ", self.irq),
683 ]
684 }
685}
686
687/// One compressed RAM block device (`/sys/block/zram<N>`).
688#[derive(Clone, Default, Debug, PartialEq, Eq)]
689pub struct ZramDevice {
690 pub name: String,
691 /// Uncompressed size of the data held, in bytes.
692 pub orig_data_size: u64,
693 /// Size actually occupied in RAM after compression, in bytes.
694 pub compr_data_size: u64,
695 /// Bytes of RAM the device has taken from the allocator.
696 pub mem_used_total: u64,
697}
698
699impl ZramDevice {
700 /// Compression ratio, `orig / compressed`. `1.0` when nothing is stored, so
701 /// a caller never divides by zero and an empty device does not claim an
702 /// infinite ratio.
703 pub fn ratio(&self) -> f32 {
704 if self.compr_data_size == 0 {
705 1.0
706 } else {
707 self.orig_data_size as f32 / self.compr_data_size as f32
708 }
709 }
710}
711
712/// Every zram device on the machine, and their totals.
713#[derive(Clone, Default, Debug, PartialEq, Eq)]
714pub struct ZramInfo {
715 pub devices: Vec<ZramDevice>,
716}
717
718impl ZramInfo {
719 pub fn present(&self) -> bool {
720 !self.devices.is_empty()
721 }
722
723 pub fn orig_total(&self) -> u64 {
724 self.devices.iter().map(|d| d.orig_data_size).sum()
725 }
726
727 pub fn compr_total(&self) -> u64 {
728 self.devices.iter().map(|d| d.compr_data_size).sum()
729 }
730
731 pub fn mem_total(&self) -> u64 {
732 self.devices.iter().map(|d| d.mem_used_total).sum()
733 }
734
735 /// Aggregate ratio across all devices. Computed from the totals rather than
736 /// averaged over the devices, so a 1 MB and a 1 GB device do not count
737 /// equally.
738 pub fn ratio(&self) -> f32 {
739 let compr = self.compr_total();
740 if compr == 0 {
741 1.0
742 } else {
743 self.orig_total() as f32 / compr as f32
744 }
745 }
746}
747
748/// One immutable sample handed from the sampler thread to the renderer.
749#[derive(Clone, Debug)]
750pub struct SystemSnapshot {
751 pub at: Instant,
752 /// Index 0 is the aggregate; indices 1..=n are per-core.
753 pub cpu: Vec<CpuTimes>,
754 /// Same shape as `cpu`, holding the delta against the previous sample.
755 pub cpu_delta: Vec<CpuTimes>,
756 pub mem: MemInfo,
757 pub swap: SwapInfo,
758 pub load: [f64; 3],
759 pub uptime: u64,
760 pub clk_tck: u64,
761 pub page_size: u64,
762 /// Cumulative host network counters, loopback excluded.
763 pub net: NetCounters,
764 /// Throughput against the previous sample. Zero on the first sample.
765 pub net_rate: Rate,
766 /// Cumulative host disk counters.
767 pub disk: DiskCounters,
768 /// Throughput against the previous sample. Zero on the first sample.
769 pub disk_rate: Rate,
770 /// Filled in by the sampler once the process table has been read.
771 pub tasks: TaskStats,
772 /// Pressure stall information. `None` where the kernel does not provide it
773 /// (macOS, or a Linux kernel older than 4.20).
774 pub psi: Option<Psi>,
775 /// Compressed RAM block devices. Empty when the machine has none.
776 pub zram: ZramInfo,
777}
778
779impl Default for SystemSnapshot {
780 fn default() -> Self {
781 Self {
782 at: Instant::now(),
783 cpu: Vec::new(),
784 cpu_delta: Vec::new(),
785 mem: MemInfo::default(),
786 swap: SwapInfo::default(),
787 load: [0.0; 3],
788 uptime: 0,
789 clk_tck: 100,
790 page_size: 4096,
791 net: NetCounters::default(),
792 net_rate: Rate::default(),
793 disk: DiskCounters::default(),
794 disk_rate: Rate::default(),
795 tasks: TaskStats::default(),
796 psi: None,
797 zram: ZramInfo::default(),
798 }
799 }
800}
801
802impl SystemSnapshot {
803 pub fn cpu_count(&self) -> usize {
804 self.cpu.len().saturating_sub(1)
805 }
806
807 /// Aggregate CPU usage in `0.0..=1.0`, including all busy segments.
808 pub fn cpu_usage(&self) -> f32 {
809 let d = match self.cpu_delta.first() {
810 Some(d) => d,
811 None => return f32::NAN,
812 };
813 let total = d.total();
814 if total == 0 {
815 return 0.0;
816 }
817 d.busy() as f32 / total as f32
818 }
819
820 pub fn mem_usage(&self) -> f32 {
821 if self.mem.total == 0 {
822 return f32::NAN;
823 }
824 self.mem.used as f32 / self.mem.total as f32
825 }
826
827 pub fn swap_usage(&self) -> f32 {
828 if self.swap.total == 0 {
829 return 0.0;
830 }
831 self.swap.used as f32 / self.swap.total as f32
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 #[test]
840 fn delta_is_tick_wise() {
841 let a = CpuTimes {
842 user: 10,
843 idle: 90,
844 ..Default::default()
845 };
846 let b = CpuTimes {
847 user: 30,
848 idle: 170,
849 ..Default::default()
850 };
851 let d = b.delta(&a);
852 assert_eq!(d.user, 20);
853 assert_eq!(d.idle, 80);
854 assert_eq!(d.total(), 100);
855 assert_eq!(d.busy(), 20);
856 }
857
858 #[test]
859 fn delta_never_wraps() {
860 let a = CpuTimes {
861 user: 30,
862 ..Default::default()
863 };
864 let b = CpuTimes {
865 user: 10,
866 ..Default::default()
867 };
868 assert_eq!(b.delta(&a).user, 0);
869 }
870
871 #[test]
872 fn linux_accounting_matches_free() {
873 let mut mem = MemInfo {
874 total: 1000,
875 free: 100,
876 buffers: 50,
877 cached: 200,
878 sreclaimable: 100,
879 shmem: 20,
880 available: 400,
881 ..Default::default()
882 };
883 mem.finish_linux();
884 // used = total - free - cached - sreclaimable - buffers
885 assert_eq!(mem.used, 1000 - 100 - 200 - 100 - 50);
886 // cached is reported net of shmem
887 assert_eq!(mem.cached, 280);
888 assert!(mem.is_consistent(), "{mem:?}");
889 }
890
891 #[test]
892 fn linux_accounting_keeps_the_invariant_on_bad_input() {
893 // A kernel that reports available > total must not break the invariant
894 // nor overflow.
895 let mut mem = MemInfo {
896 total: 100,
897 available: 500,
898 free: 10,
899 ..Default::default()
900 };
901 mem.finish_linux();
902 assert!(mem.is_consistent(), "{mem:?}");
903 assert!(mem.used <= mem.total);
904 assert!(mem.available <= mem.total);
905 }
906
907 /// Real `vm_statistics64` numbers from a 16 GB Apple Silicon Mac (page size
908 /// 16384) at the moment htop 3.4.1 displayed `13.3G/16.0G`, while gander
909 /// still showed `5.3G/16.0G`.
910 ///
911 /// The gap was exactly the compressor: htop 3.4.1 prints
912 /// `USED + COMPRESSED` (`MemoryMeter_updateValues`), which simplifies to
913 /// `internal + wired + compressed`. The old `used = active + wired` formula
914 /// left out both the compressor and `internal - active`, i.e. most of what
915 /// the machine was actually using.
916 #[test]
917 fn darwin_used_matches_htop_341() {
918 const PAGE: u64 = 16 * 1024;
919 let mut mem = MemInfo {
920 total: 16 * 1024 * 1024 * 1024,
921 // vm_stat, verbatim.
922 internal: 301_497 * PAGE,
923 active: 219_954 * PAGE,
924 inactive: 216_885 * PAGE,
925 speculative: 2_022 * PAGE,
926 wired: 136_897 * PAGE,
927 compressed: 429_207 * PAGE,
928 purgeable: 0,
929 free: 4_007 * PAGE,
930 ..Default::default()
931 };
932 mem.finish_darwin();
933
934 // htop's two printed terms: its USED value, which excludes the
935 // compressor, plus its COMPRESSED value.
936 let htop_used_excl_compressor = 301_497 * PAGE + 136_897 * PAGE;
937 let htop_compressed = 429_207 * PAGE;
938 assert_eq!(
939 mem.used,
940 htop_used_excl_compressor + htop_compressed,
941 "gander's used must equal htop's USED + COMPRESSED"
942 );
943 assert_eq!(mem.used_from_classes(), mem.used);
944 assert!(mem.is_exactly_partitioned());
945
946 // The compressor is no longer the missing 7 GB.
947 assert_eq!(
948 mem.used - (mem.internal - mem.purgeable) - mem.wired,
949 htop_compressed,
950 "the remainder after active+wired is the compressor"
951 );
952
953 // htop 3.4 also counts inactive/speculative as cache, not as used. They
954 // must not be added on top here, or used would be ~3.4 GB too high.
955 assert!(mem.used < mem.total);
956
957 let labels: Vec<&str> = mem.classes.iter().map(|c| c.label).collect();
958 assert_eq!(
959 labels,
960 vec![
961 "wired",
962 "active",
963 "compressed",
964 "purgeable",
965 "cache",
966 "free"
967 ]
968 );
969 }
970
971 /// The bar must not claim free memory the compressor is holding.
972 ///
973 /// This is the second half of the same bug: the old formula derived the
974 /// `free` segment as `total - used - purgeable - inactive`, so the ~7 GB the
975 /// compressor occupied landed in the free segment. A machine with 0.3 GB
976 /// free drew a bar that was nearly half empty.
977 #[test]
978 fn darwin_free_segment_is_not_the_compressor() {
979 const PAGE: u64 = 16 * 1024;
980 let mut mem = MemInfo {
981 total: 16 * 1024 * 1024 * 1024,
982 internal: 301_497 * PAGE,
983 wired: 136_897 * PAGE,
984 compressed: 429_207 * PAGE,
985 purgeable: 0,
986 free: 4_007 * PAGE,
987 ..Default::default()
988 };
989 mem.finish_darwin();
990
991 let free_class = mem
992 .classes
993 .iter()
994 .find(|c| c.label == "free")
995 .expect("a free class is always present");
996 // At most ~130 MiB of free memory exists on this sample; anything in the
997 // gigabytes means the compressor fell through into the wrong segment.
998 assert!(
999 free_class.bytes < 1024 * 1024 * 1024,
1000 "free segment is {} MiB, which is the compressor leaking in",
1001 free_class.bytes / (1024 * 1024)
1002 );
1003 }
1004
1005 #[test]
1006 fn darwin_bar_segments_partition_total() {
1007 // Every class flagged `counts_as_total` is a distinct set of pages, so the
1008 // segments must add up to physical memory exactly -- after clamping, on
1009 // absurd input included.
1010 for (internal, wired, compressed, purgeable) in [
1011 (0u64, 0u64, 0u64, 0u64),
1012 (500, 300, 200, 0),
1013 (500, 300, 200, 700), // purgeable > internal: must not underflow
1014 (400, 300, 300, 0), // sums to total already
1015 (4000, 3000, 3000, 0), // over-subscribed: must clamp
1016 ] {
1017 let mut mem = MemInfo {
1018 total: 1000,
1019 internal,
1020 wired,
1021 compressed,
1022 purgeable,
1023 ..Default::default()
1024 };
1025 mem.finish_darwin();
1026 let classes: u64 = mem
1027 .classes
1028 .iter()
1029 .filter(|c| c.counts_as_total)
1030 .map(|c| c.bytes)
1031 .sum();
1032 assert_eq!(classes, mem.total, "{mem:?}");
1033 assert!(mem.is_exactly_partitioned(), "{mem:?}");
1034 assert!(mem.used <= mem.total, "{mem:?}");
1035 }
1036 }
1037
1038 #[test]
1039 fn zero_delta_is_not_nan() {
1040 let snap = SystemSnapshot {
1041 cpu_delta: vec![CpuTimes::default()],
1042 ..Default::default()
1043 };
1044 assert_eq!(snap.cpu_usage(), 0.0);
1045 }
1046}