Skip to main content

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}