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, 1 running`; these are those numbers.
495#[derive(Copy, Clone, Default, Debug)]
496pub struct TaskStats {
497 /// Rows in the process table.
498 pub total: usize,
499 /// Processes in state `R`.
500 pub running: usize,
501 /// Sum of `NLWP` across the table.
502 pub threads: usize,
503}
504
505#[derive(Copy, Clone, Default, Debug)]
506pub struct SwapInfo {
507 pub total: u64,
508 pub free: u64,
509 pub used: u64,
510}
511
512/// Host-wide network byte counters, cumulative since boot.
513///
514/// Loopback is excluded by both backends: `lo` traffic never reaches a wire, and
515/// counting it would both inflate the figure and double every byte (once as
516/// received, once as transmitted). A container or a busy local database would
517/// otherwise report hundreds of MB/s of "network" while the NIC is idle.
518#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
519pub struct NetCounters {
520 pub rx_bytes: u64,
521 pub tx_bytes: u64,
522}
523
524impl NetCounters {
525 /// Counter delta against an earlier sample, saturating on reset.
526 ///
527 /// A counter can go *backwards*: an interface is destroyed and recreated, or
528 /// the kernel wraps the counter on 32-bit `if_data`. Reporting a negative
529 /// rate (or a 4 GiB spike) for one tick is worse than reporting zero, so a
530 /// decreasing counter contributes nothing.
531 pub fn delta(&self, prev: &NetCounters) -> NetCounters {
532 NetCounters {
533 rx_bytes: self.rx_bytes.saturating_sub(prev.rx_bytes),
534 tx_bytes: self.tx_bytes.saturating_sub(prev.tx_bytes),
535 }
536 }
537
538 /// Bytes per second between two samples `secs` apart.
539 pub fn per_sec(&self, prev: &NetCounters, secs: f64) -> Rate {
540 let d = self.delta(prev);
541 Rate::from_bytes(d.rx_bytes, d.tx_bytes, secs)
542 }
543}
544
545/// Host-wide disk byte counters, cumulative since boot.
546#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
547pub struct DiskCounters {
548 pub read_bytes: u64,
549 pub write_bytes: u64,
550}
551
552impl DiskCounters {
553 pub fn delta(&self, prev: &DiskCounters) -> DiskCounters {
554 DiskCounters {
555 read_bytes: self.read_bytes.saturating_sub(prev.read_bytes),
556 write_bytes: self.write_bytes.saturating_sub(prev.write_bytes),
557 }
558 }
559
560 pub fn per_sec(&self, prev: &DiskCounters, secs: f64) -> Rate {
561 let d = self.delta(prev);
562 Rate::from_bytes(d.read_bytes, d.write_bytes, secs)
563 }
564}
565
566/// A throughput in bytes per second, split into its two directions.
567///
568/// Kept as `f64` rather than `u64` because a 1.5 s interval divides a small
569/// delta into a fraction; truncating there made a steady 3 B/s read show as
570/// alternating 0 and 6.
571#[derive(Copy, Clone, Default, Debug, PartialEq)]
572pub struct Rate {
573 /// Received (net) or read (disk).
574 pub inbound: f64,
575 /// Transmitted (net) or written (disk).
576 pub outbound: f64,
577}
578
579impl Rate {
580 fn from_bytes(inbound: u64, outbound: u64, secs: f64) -> Self {
581 if secs <= 0.0 || secs.is_nan() {
582 // First sample: there is no interval to divide by. Zero, not NaN, so
583 // a meter never has to special-case its first frame.
584 return Self::default();
585 }
586 Self {
587 inbound: inbound as f64 / secs,
588 outbound: outbound as f64 / secs,
589 }
590 }
591
592 /// Combined throughput, for scaling a single bar or graph.
593 pub fn total(&self) -> f64 {
594 self.inbound + self.outbound
595 }
596}
597
598/// One PSI resource's `some` and `full` averages.
599///
600/// `/proc/pressure/<resource>` reports two lines:
601///
602/// ```text
603/// some avg10=0.00 avg60=0.00 avg300=0.00 total=0
604/// full avg10=0.00 avg60=0.00 avg300=0.00 total=0
605/// ```
606///
607/// `some` is the share of wall time during which *at least one* task was
608/// stalled; `full` is the share during which *every* runnable task was stalled,
609/// i.e. the machine genuinely had nothing to run.
610///
611/// **All four resources report both lines**, `cpu` included. An earlier revision
612/// of this comment claimed `cpu` had no `full` line — that is wrong, and checking
613/// a real `/proc/pressure/cpu` (Linux 6.x) shows both. The two lines mean slightly
614/// different things for `cpu` (a task runnable-but-not-running is `some`; the
615/// whole CPU idle while tasks wait is `full`), which is exactly why the kernel
616/// reports both. Code here must not assume `has_full` is false for any resource.
617#[derive(Copy, Clone, Default, Debug, PartialEq)]
618pub struct PsiLine {
619 /// Percentage of wall time stalled, averaged over 10 s.
620 pub avg10: f32,
621 /// Percentage of wall time stalled, averaged over 60 s.
622 pub avg60: f32,
623 /// Percentage of wall time stalled, averaged over 300 s.
624 pub avg300: f32,
625 /// Total microseconds stalled since boot. Cumulative, so it can be used for
626 /// a rate the same way the network counters are.
627 pub total_us: u64,
628}
629
630/// Pressure stall information for one resource.
631#[derive(Copy, Clone, Default, Debug, PartialEq)]
632pub struct PsiResource {
633 /// Readable at all. `false` means the file was missing.
634 pub present: bool,
635 /// The `some` line.
636 pub some: PsiLine,
637 /// The `full` line. Left at zero when the file has none.
638 pub full: PsiLine,
639 /// Whether this resource's file carried a `full` line.
640 ///
641 /// True for every resource on current kernels, `cpu` included. Kept because
642 /// it is the file that decides, not us: a future kernel is free to drop a
643 /// line, and a meter that assumed one was present would draw a fabricated
644 /// zero instead of leaving the value out.
645 pub has_full: bool,
646}
647
648/// Host pressure stall information.
649///
650/// # Why this exists on top of CPU% and load average
651///
652/// Neither of those answers "is the machine actually struggling". A box at 90 %
653/// CPU with nothing waiting is busy but healthy; a box at 30 % CPU with an `io`
654/// `full` average of 40 % is making every process wait on the disk, and both
655/// CPU% and load average look unremarkable while it happens. PSI is the kernel's
656/// own answer to "how much time did work actually lose", and it is the one
657/// number that distinguishes the two cases.
658#[derive(Copy, Clone, Default, Debug, PartialEq)]
659pub struct Psi {
660 pub cpu: PsiResource,
661 pub memory: PsiResource,
662 pub io: PsiResource,
663 pub irq: PsiResource,
664}
665
666impl Psi {
667 /// Whether any resource was readable. A `Psi` where nothing is present is
668 /// worse than `None`: it draws three empty bars that look like an idle
669 /// machine instead of an absent feature.
670 pub fn any_present(&self) -> bool {
671 self.cpu.present || self.memory.present || self.io.present || self.irq.present
672 }
673
674 /// The resources in display order, with their labels.
675 pub fn rows(&self) -> [(&'static str, PsiResource); 4] {
676 [
677 ("CPU", self.cpu),
678 ("MEM", self.memory),
679 ("IO", self.io),
680 ("IRQ", self.irq),
681 ]
682 }
683}
684
685/// One compressed RAM block device (`/sys/block/zram<N>`).
686#[derive(Clone, Default, Debug, PartialEq, Eq)]
687pub struct ZramDevice {
688 pub name: String,
689 /// Uncompressed size of the data held, in bytes.
690 pub orig_data_size: u64,
691 /// Size actually occupied in RAM after compression, in bytes.
692 pub compr_data_size: u64,
693 /// Bytes of RAM the device has taken from the allocator.
694 pub mem_used_total: u64,
695}
696
697impl ZramDevice {
698 /// Compression ratio, `orig / compressed`. `1.0` when nothing is stored, so
699 /// a caller never divides by zero and an empty device does not claim an
700 /// infinite ratio.
701 pub fn ratio(&self) -> f32 {
702 if self.compr_data_size == 0 {
703 1.0
704 } else {
705 self.orig_data_size as f32 / self.compr_data_size as f32
706 }
707 }
708}
709
710/// Every zram device on the machine, and their totals.
711#[derive(Clone, Default, Debug, PartialEq, Eq)]
712pub struct ZramInfo {
713 pub devices: Vec<ZramDevice>,
714}
715
716impl ZramInfo {
717 pub fn present(&self) -> bool {
718 !self.devices.is_empty()
719 }
720
721 pub fn orig_total(&self) -> u64 {
722 self.devices.iter().map(|d| d.orig_data_size).sum()
723 }
724
725 pub fn compr_total(&self) -> u64 {
726 self.devices.iter().map(|d| d.compr_data_size).sum()
727 }
728
729 pub fn mem_total(&self) -> u64 {
730 self.devices.iter().map(|d| d.mem_used_total).sum()
731 }
732
733 /// Aggregate ratio across all devices. Computed from the totals rather than
734 /// averaged over the devices, so a 1 MB and a 1 GB device do not count
735 /// equally.
736 pub fn ratio(&self) -> f32 {
737 let compr = self.compr_total();
738 if compr == 0 {
739 1.0
740 } else {
741 self.orig_total() as f32 / compr as f32
742 }
743 }
744}
745
746/// One immutable sample handed from the sampler thread to the renderer.
747#[derive(Clone, Debug)]
748pub struct SystemSnapshot {
749 pub at: Instant,
750 /// Index 0 is the aggregate; indices 1..=n are per-core.
751 pub cpu: Vec<CpuTimes>,
752 /// Same shape as `cpu`, holding the delta against the previous sample.
753 pub cpu_delta: Vec<CpuTimes>,
754 pub mem: MemInfo,
755 pub swap: SwapInfo,
756 pub load: [f64; 3],
757 pub uptime: u64,
758 pub clk_tck: u64,
759 pub page_size: u64,
760 /// Cumulative host network counters, loopback excluded.
761 pub net: NetCounters,
762 /// Throughput against the previous sample. Zero on the first sample.
763 pub net_rate: Rate,
764 /// Cumulative host disk counters.
765 pub disk: DiskCounters,
766 /// Throughput against the previous sample. Zero on the first sample.
767 pub disk_rate: Rate,
768 /// Filled in by the sampler once the process table has been read.
769 pub tasks: TaskStats,
770 /// Pressure stall information. `None` where the kernel does not provide it
771 /// (macOS, or a Linux kernel older than 4.20).
772 pub psi: Option<Psi>,
773 /// Compressed RAM block devices. Empty when the machine has none.
774 pub zram: ZramInfo,
775}
776
777impl Default for SystemSnapshot {
778 fn default() -> Self {
779 Self {
780 at: Instant::now(),
781 cpu: Vec::new(),
782 cpu_delta: Vec::new(),
783 mem: MemInfo::default(),
784 swap: SwapInfo::default(),
785 load: [0.0; 3],
786 uptime: 0,
787 clk_tck: 100,
788 page_size: 4096,
789 net: NetCounters::default(),
790 net_rate: Rate::default(),
791 disk: DiskCounters::default(),
792 disk_rate: Rate::default(),
793 tasks: TaskStats::default(),
794 psi: None,
795 zram: ZramInfo::default(),
796 }
797 }
798}
799
800impl SystemSnapshot {
801 pub fn cpu_count(&self) -> usize {
802 self.cpu.len().saturating_sub(1)
803 }
804
805 /// Aggregate CPU usage in `0.0..=1.0`, including all busy segments.
806 pub fn cpu_usage(&self) -> f32 {
807 let d = match self.cpu_delta.first() {
808 Some(d) => d,
809 None => return f32::NAN,
810 };
811 let total = d.total();
812 if total == 0 {
813 return 0.0;
814 }
815 d.busy() as f32 / total as f32
816 }
817
818 pub fn mem_usage(&self) -> f32 {
819 if self.mem.total == 0 {
820 return f32::NAN;
821 }
822 self.mem.used as f32 / self.mem.total as f32
823 }
824
825 pub fn swap_usage(&self) -> f32 {
826 if self.swap.total == 0 {
827 return 0.0;
828 }
829 self.swap.used as f32 / self.swap.total as f32
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836
837 #[test]
838 fn delta_is_tick_wise() {
839 let a = CpuTimes {
840 user: 10,
841 idle: 90,
842 ..Default::default()
843 };
844 let b = CpuTimes {
845 user: 30,
846 idle: 170,
847 ..Default::default()
848 };
849 let d = b.delta(&a);
850 assert_eq!(d.user, 20);
851 assert_eq!(d.idle, 80);
852 assert_eq!(d.total(), 100);
853 assert_eq!(d.busy(), 20);
854 }
855
856 #[test]
857 fn delta_never_wraps() {
858 let a = CpuTimes {
859 user: 30,
860 ..Default::default()
861 };
862 let b = CpuTimes {
863 user: 10,
864 ..Default::default()
865 };
866 assert_eq!(b.delta(&a).user, 0);
867 }
868
869 #[test]
870 fn linux_accounting_matches_free() {
871 let mut mem = MemInfo {
872 total: 1000,
873 free: 100,
874 buffers: 50,
875 cached: 200,
876 sreclaimable: 100,
877 shmem: 20,
878 available: 400,
879 ..Default::default()
880 };
881 mem.finish_linux();
882 // used = total - free - cached - sreclaimable - buffers
883 assert_eq!(mem.used, 1000 - 100 - 200 - 100 - 50);
884 // cached is reported net of shmem
885 assert_eq!(mem.cached, 280);
886 assert!(mem.is_consistent(), "{mem:?}");
887 }
888
889 #[test]
890 fn linux_accounting_keeps_the_invariant_on_bad_input() {
891 // A kernel that reports available > total must not break the invariant
892 // nor overflow.
893 let mut mem = MemInfo {
894 total: 100,
895 available: 500,
896 free: 10,
897 ..Default::default()
898 };
899 mem.finish_linux();
900 assert!(mem.is_consistent(), "{mem:?}");
901 assert!(mem.used <= mem.total);
902 assert!(mem.available <= mem.total);
903 }
904
905 /// Real `vm_statistics64` numbers from a 16 GB Apple Silicon Mac (page size
906 /// 16384) at the moment htop 3.4.1 displayed `13.3G/16.0G`, while gander
907 /// still showed `5.3G/16.0G`.
908 ///
909 /// The gap was exactly the compressor: htop 3.4.1 prints
910 /// `USED + COMPRESSED` (`MemoryMeter_updateValues`), which simplifies to
911 /// `internal + wired + compressed`. The old `used = active + wired` formula
912 /// left out both the compressor and `internal - active`, i.e. most of what
913 /// the machine was actually using.
914 #[test]
915 fn darwin_used_matches_htop_341() {
916 const PAGE: u64 = 16 * 1024;
917 let mut mem = MemInfo {
918 total: 16 * 1024 * 1024 * 1024,
919 // vm_stat, verbatim.
920 internal: 301_497 * PAGE,
921 active: 219_954 * PAGE,
922 inactive: 216_885 * PAGE,
923 speculative: 2_022 * PAGE,
924 wired: 136_897 * PAGE,
925 compressed: 429_207 * PAGE,
926 purgeable: 0,
927 free: 4_007 * PAGE,
928 ..Default::default()
929 };
930 mem.finish_darwin();
931
932 // htop's two printed terms: its USED value, which excludes the
933 // compressor, plus its COMPRESSED value.
934 let htop_used_excl_compressor = 301_497 * PAGE + 136_897 * PAGE;
935 let htop_compressed = 429_207 * PAGE;
936 assert_eq!(
937 mem.used,
938 htop_used_excl_compressor + htop_compressed,
939 "gander's used must equal htop's USED + COMPRESSED"
940 );
941 assert_eq!(mem.used_from_classes(), mem.used);
942 assert!(mem.is_exactly_partitioned());
943
944 // The compressor is no longer the missing 7 GB.
945 assert_eq!(
946 mem.used - (mem.internal - mem.purgeable) - mem.wired,
947 htop_compressed,
948 "the remainder after active+wired is the compressor"
949 );
950
951 // htop 3.4 also counts inactive/speculative as cache, not as used. They
952 // must not be added on top here, or used would be ~3.4 GB too high.
953 assert!(mem.used < mem.total);
954
955 let labels: Vec<&str> = mem.classes.iter().map(|c| c.label).collect();
956 assert_eq!(
957 labels,
958 vec![
959 "wired",
960 "active",
961 "compressed",
962 "purgeable",
963 "cache",
964 "free"
965 ]
966 );
967 }
968
969 /// The bar must not claim free memory the compressor is holding.
970 ///
971 /// This is the second half of the same bug: the old formula derived the
972 /// `free` segment as `total - used - purgeable - inactive`, so the ~7 GB the
973 /// compressor occupied landed in the free segment. A machine with 0.3 GB
974 /// free drew a bar that was nearly half empty.
975 #[test]
976 fn darwin_free_segment_is_not_the_compressor() {
977 const PAGE: u64 = 16 * 1024;
978 let mut mem = MemInfo {
979 total: 16 * 1024 * 1024 * 1024,
980 internal: 301_497 * PAGE,
981 wired: 136_897 * PAGE,
982 compressed: 429_207 * PAGE,
983 purgeable: 0,
984 free: 4_007 * PAGE,
985 ..Default::default()
986 };
987 mem.finish_darwin();
988
989 let free_class = mem
990 .classes
991 .iter()
992 .find(|c| c.label == "free")
993 .expect("a free class is always present");
994 // At most ~130 MiB of free memory exists on this sample; anything in the
995 // gigabytes means the compressor fell through into the wrong segment.
996 assert!(
997 free_class.bytes < 1024 * 1024 * 1024,
998 "free segment is {} MiB, which is the compressor leaking in",
999 free_class.bytes / (1024 * 1024)
1000 );
1001 }
1002
1003 #[test]
1004 fn darwin_bar_segments_partition_total() {
1005 // Every class flagged `counts_as_total` is a distinct set of pages, so the
1006 // segments must add up to physical memory exactly -- after clamping, on
1007 // absurd input included.
1008 for (internal, wired, compressed, purgeable) in [
1009 (0u64, 0u64, 0u64, 0u64),
1010 (500, 300, 200, 0),
1011 (500, 300, 200, 700), // purgeable > internal: must not underflow
1012 (400, 300, 300, 0), // sums to total already
1013 (4000, 3000, 3000, 0), // over-subscribed: must clamp
1014 ] {
1015 let mut mem = MemInfo {
1016 total: 1000,
1017 internal,
1018 wired,
1019 compressed,
1020 purgeable,
1021 ..Default::default()
1022 };
1023 mem.finish_darwin();
1024 let classes: u64 = mem
1025 .classes
1026 .iter()
1027 .filter(|c| c.counts_as_total)
1028 .map(|c| c.bytes)
1029 .sum();
1030 assert_eq!(classes, mem.total, "{mem:?}");
1031 assert!(mem.is_exactly_partitioned(), "{mem:?}");
1032 assert!(mem.used <= mem.total, "{mem:?}");
1033 }
1034 }
1035
1036 #[test]
1037 fn zero_delta_is_not_nan() {
1038 let snap = SystemSnapshot {
1039 cpu_delta: vec![CpuTimes::default()],
1040 ..Default::default()
1041 };
1042 assert_eq!(snap.cpu_usage(), 0.0);
1043 }
1044}