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, 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}