Skip to main content

retch_sysinfo/
io.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Disk and network I/O throughput sampling.
5//!
6//! Both fields report a **rate**, which a one-shot process cannot read directly: the
7//! kernel exposes cumulative counters, so a rate needs two samples and a known interval.
8//! fastfetch solves this with a dedicated ~1 s sleep (measured: `fastfetch -s NetIO`
9//! takes 1.00 s against 0.00 s for a counter-only module). retch cannot afford that —
10//! `--long` targets ~500 ms end to end, and being slower than fastfetch is treated as a
11//! blocking regression (NOTES.md §3, "Performance Regression Vigilance").
12//!
13//! So this module follows the v0.3.49 `cpu-usage` pattern instead: [`fetch`] samples the
14//! counters *before* the concurrent probe scope and diffs them *after*, making the
15//! existing collection window the sampling interval. A floor is applied only when the
16//! window came out too short to measure anything (an isolated `--fields disk-io`), which
17//! is the sole case where these fields add any wall-clock at all.
18//!
19//! The consequence, stated plainly because it is the honest reading of the number: the
20//! window varies by mode — roughly 0.4 s in `--long`, seconds in `--full` — so the value
21//! is the *average* rate over the run, not an instantaneous one. That is the right
22//! trade for a fetcher; a stable window would cost a sleep on every invocation.
23//!
24//! [`fetch`]: crate::fetch
25
26/// Cumulative byte counters for one device at a point in time.
27///
28/// `read`/`write` are disk semantics; for network interfaces they carry RX/TX
29/// respectively, since the rate arithmetic is identical and only the labels differ.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct IoCounters {
32    /// Kernel device name (`nvme0n1`, `wlp0s20f3`).
33    pub device: String,
34    /// Bytes read (disk) or received (network) since boot.
35    pub read: u64,
36    /// Bytes written (disk) or transmitted (network) since boot.
37    pub write: u64,
38}
39
40/// A device's throughput over the sampling window, in bytes per second.
41#[derive(Debug, Clone, PartialEq)]
42pub struct IoRate {
43    /// Kernel device name.
44    pub device: String,
45    /// Read/RX rate in bytes per second.
46    pub read: f64,
47    /// Write/TX rate in bytes per second.
48    pub write: f64,
49}
50
51/// Bytes per sector in `/proc/diskstats`.
52///
53/// **This is a fixed kernel convention, not the device's sector size.** diskstats reports
54/// in 512-byte bio sectors regardless of what `/sys/block/<dev>/queue/hw_sector_size`
55/// says, so keying this off the hardware value inflates every figure 8× on a 4 KiB-sector
56/// drive. `disk.rs` already relies on the same convention for `/sys/block/<dev>/size`.
57///
58/// Confirmed here by writing a known 64 MiB of incompressible data and reading the delta:
59/// 146808 sectors, i.e. 71 MiB at 512 B/sector (the excess is btrfs metadata and CoW)
60/// against an impossible 573 MiB at 4096. **Verification limit, recorded rather than
61/// papered over:** the host used for that check has `hw_sector_size` 512 itself, so the
62/// result confirms the value without discriminating the two rules. A device with a 4 KiB
63/// logical sector would separate them.
64const DISKSTATS_SECTOR_BYTES: u64 = 512;
65
66/// Field index of "sectors read" in a `/proc/diskstats` line (0-based, after splitting on
67/// whitespace): major, minor, name, reads completed, reads merged, **sectors read**.
68const DISKSTATS_SECTORS_READ: usize = 5;
69
70/// Field index of "sectors written": … ms reading, writes completed, writes merged,
71/// **sectors written**.
72const DISKSTATS_SECTORS_WRITTEN: usize = 9;
73
74/// Parses `/proc/diskstats` into per-device byte counters.
75///
76/// `keep` decides which device names survive; it is injected rather than hardcoded so the
77/// tests can assert against a verbatim fixture without depending on the block devices of
78/// whatever machine runs them — the #155/v0.6.2 lesson, where a "parse this fixture" test
79/// silently consulted live hardware and failed on one developer's box.
80///
81/// Lines with too few fields or unparsable counters are skipped rather than defaulted to
82/// zero: a zero would render as a confident `0 B/s` for a device that was never read.
83pub fn parse_diskstats<F>(content: &str, keep: F) -> Vec<IoCounters>
84where
85    F: Fn(&str) -> bool,
86{
87    let mut out = Vec::new();
88    for line in content.lines() {
89        let fields: Vec<&str> = line.split_whitespace().collect();
90        if fields.len() <= DISKSTATS_SECTORS_WRITTEN {
91            continue;
92        }
93        let name = fields[2];
94        if !keep(name) {
95            continue;
96        }
97        let (Ok(read_sectors), Ok(written_sectors)) = (
98            fields[DISKSTATS_SECTORS_READ].parse::<u64>(),
99            fields[DISKSTATS_SECTORS_WRITTEN].parse::<u64>(),
100        ) else {
101            continue;
102        };
103        out.push(IoCounters {
104            device: name.to_string(),
105            read: read_sectors.saturating_mul(DISKSTATS_SECTOR_BYTES),
106            write: written_sectors.saturating_mul(DISKSTATS_SECTOR_BYTES),
107        });
108    }
109    out
110}
111
112/// Computes per-device rates between two samples taken `elapsed_secs` apart.
113///
114/// A device present in only one sample is **dropped**, not reported: an interface that
115/// appeared mid-run (a VPN link coming up) has no baseline, and treating its lifetime
116/// counter as a delta would render a spectacular fictional rate.
117///
118/// Counter decreases are clamped to zero via `saturating_sub`. Counters do reset in
119/// practice — an interface going down and up, a module reload — and a wrapped subtraction
120/// would produce an exabyte-scale rate from a perfectly ordinary event.
121pub fn compute_rates(
122    before: &[IoCounters],
123    after: &[IoCounters],
124    elapsed_secs: f64,
125) -> Vec<IoRate> {
126    // Written as an explicit finite check rather than `<= 0.0` because a NaN window must
127    // also yield nothing: `NaN <= 0.0` is false, so the terse form would divide by it.
128    if !elapsed_secs.is_finite() || elapsed_secs <= 0.0 {
129        return Vec::new();
130    }
131    after
132        .iter()
133        .filter_map(|now| {
134            let prev = before.iter().find(|p| p.device == now.device)?;
135            Some(IoRate {
136                device: now.device.clone(),
137                read: now.read.saturating_sub(prev.read) as f64 / elapsed_secs,
138                write: now.write.saturating_sub(prev.write) as f64 / elapsed_secs,
139            })
140        })
141        .collect()
142}
143
144/// Formats a byte-per-second rate for display (`"1.2 MB/s"`).
145///
146/// Delegates to [`crate::network::format_bytes`] so the unit vocabulary matches the `Net`
147/// field's existing `RX:`/`TX:` totals rather than introducing a second scheme alongside
148/// it. Non-finite and negative inputs render as `0 B/s`; they cannot arise from
149/// [`compute_rates`], but the formatter is public and should not print `NaN B/s`.
150pub fn format_rate(bytes_per_sec: f64) -> String {
151    let clamped = if bytes_per_sec.is_finite() && bytes_per_sec > 0.0 {
152        bytes_per_sec.round() as u64
153    } else {
154        0
155    };
156    format!("{}/s", crate::network::format_bytes(clamped))
157}
158
159/// Renders one device's rates as a display line, e.g.
160/// `"nvme0n1 R: 1.2 MB/s W: 0 B/s"`.
161pub fn format_io_line(rate: &IoRate, read_label: &str, write_label: &str) -> String {
162    format!(
163        "{} {}: {} {}: {}",
164        rate.device,
165        read_label,
166        format_rate(rate.read),
167        write_label,
168        format_rate(rate.write)
169    )
170}
171
172/// Chooses which interfaces the `net-io` field reports.
173///
174/// The default-route interface when it is known — matching both fastfetch and the way the
175/// `Net` field already singles that interface out. Otherwise (offline, or the active
176/// interface could not be resolved) every interface that actually moved bytes during the
177/// window, so an unusual routing setup still reports something rather than nothing.
178///
179/// Returning an empty list when the active interface is known but idle is deliberate: a
180/// `0 B/s` line for the interface you are using is a real, informative reading.
181pub fn select_net_rates(rates: Vec<IoRate>, active: Option<&str>) -> Vec<IoRate> {
182    if let Some(active) = active {
183        let selected: Vec<IoRate> = rates
184            .iter()
185            .filter(|r| r.device == active)
186            .cloned()
187            .collect();
188        if !selected.is_empty() {
189            return selected;
190        }
191    }
192    rates
193        .into_iter()
194        .filter(|r| r.read > 0.0 || r.write > 0.0)
195        .collect()
196}
197
198/// Samples cumulative disk byte counters for physical whole disks.
199///
200/// Linux only; returns an empty vector elsewhere, so the field is simply absent rather
201/// than wrong (same shape as `brightness`, `keyboard`, `tpm`). Partitions are excluded
202/// because their traffic is already counted against the parent device — reporting both
203/// would double the apparent throughput of every disk.
204pub fn sample_disk_io() -> Vec<IoCounters> {
205    #[cfg(target_os = "linux")]
206    {
207        let Ok(content) = std::fs::read_to_string("/proc/diskstats") else {
208            return Vec::new();
209        };
210        parse_diskstats(&content, is_physical_disk)
211    }
212
213    #[cfg(not(target_os = "linux"))]
214    {
215        Vec::new()
216    }
217}
218
219/// True when `name` is a physical whole disk rather than a partition or virtual device.
220///
221/// Shares [`crate::disk::is_virtual_block_name`] with the `phys-disk` field so the two
222/// cannot drift into disagreeing about what counts as a disk, then applies the same two
223/// sysfs tests `disk::detect_linux` uses: partitions carry a `partition` file, and a real
224/// block device has a `queue` directory.
225#[cfg(target_os = "linux")]
226fn is_physical_disk(name: &str) -> bool {
227    if crate::disk::is_virtual_block_name(name) {
228        return false;
229    }
230    let dev = std::path::Path::new("/sys/class/block").join(name);
231    !dev.join("partition").exists() && dev.join("queue").exists()
232}
233
234/// Samples cumulative network byte counters per interface.
235///
236/// Reads `/sys/class/net/<iface>/statistics/{rx,tx}_bytes` directly rather than going
237/// through sysinfo, so the two samples are guaranteed to come from the same source and
238/// the same units as each other. Loopback is excluded — its traffic is the machine
239/// talking to itself and says nothing about network throughput.
240///
241/// Linux only; empty elsewhere.
242pub fn sample_net_io() -> Vec<IoCounters> {
243    #[cfg(target_os = "linux")]
244    {
245        let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
246            return Vec::new();
247        };
248        let mut out = Vec::new();
249        for entry in entries.flatten() {
250            let name = entry.file_name().to_string_lossy().to_string();
251            if name == "lo" || name.starts_with("lo:") {
252                continue;
253            }
254            let stats = entry.path().join("statistics");
255            let read = read_counter(&stats.join("rx_bytes"));
256            let write = read_counter(&stats.join("tx_bytes"));
257            if let (Some(read), Some(write)) = (read, write) {
258                out.push(IoCounters {
259                    device: name,
260                    read,
261                    write,
262                });
263            }
264        }
265        out.sort_by(|a, b| a.device.cmp(&b.device));
266        out
267    }
268
269    #[cfg(not(target_os = "linux"))]
270    {
271        Vec::new()
272    }
273}
274
275/// Reads a single unsigned counter from a sysfs file.
276#[cfg(target_os = "linux")]
277fn read_counter(path: &std::path::Path) -> Option<u64> {
278    std::fs::read_to_string(path)
279        .ok()?
280        .trim()
281        .parse::<u64>()
282        .ok()
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    /// Verbatim `/proc/diskstats` excerpt from a Fedora 44 host (corrino): one NVMe whole
290    /// disk, three of its partitions, and a zram device.
291    const DISKSTATS: &str = "\
292 259       0 nvme0n1 881658 6545 23259904 398678 110634 278 3327562 320368 0 53695 721779 6070 0 2002840 1798 2314 934
293 259       1 nvme0n1p1 338 1067 10262 173 2 0 2 0 0 24 173 0 0 0 0 0 0
294 259       2 nvme0n1p2 289 12 7954 45 22 17 288 6 0 42 51 0 0 0 0 0 0
295 259       3 nvme0n1p3 880938 5466 23238992 398446 110607 261 3327272 320361 0 65541 720606 6070 0 2002840 1798 0 0
296 251       0 zram0 46534 0 381008 231 252558 0 2769832 3263 0 5270 3494 0 0 0 0 0 0
297";
298
299    #[test]
300    fn test_parse_diskstats_reads_the_sector_columns() {
301        let parsed = parse_diskstats(DISKSTATS, |n| n == "nvme0n1");
302        assert_eq!(parsed.len(), 1);
303        // Columns 6 and 10 of the line, in 512-byte sectors.
304        assert_eq!(parsed[0].read, 23_259_904 * 512);
305        assert_eq!(parsed[0].write, 3_327_562 * 512);
306    }
307
308    #[test]
309    fn test_parse_diskstats_honors_the_injected_filter() {
310        // The filter is what keeps partitions out; without it their traffic is counted a
311        // second time against the same physical device.
312        let all = parse_diskstats(DISKSTATS, |_| true);
313        assert_eq!(all.len(), 5);
314        let whole = parse_diskstats(DISKSTATS, |n| !n.starts_with("zram") && !n.contains('p'));
315        assert_eq!(
316            whole.iter().map(|c| c.device.as_str()).collect::<Vec<_>>(),
317            vec!["nvme0n1"]
318        );
319    }
320
321    #[test]
322    fn test_parse_diskstats_skips_malformed_lines() {
323        let content = "259 0 nvme0n1 1 2\n259 0 sda 1 2 x 4 5 6 notanumber 8 9 10\n";
324        assert!(parse_diskstats(content, |_| true).is_empty());
325    }
326
327    #[test]
328    fn test_compute_rates_divides_the_delta_by_the_window() {
329        let before = vec![IoCounters {
330            device: "nvme0n1".into(),
331            read: 1_000,
332            write: 2_000,
333        }];
334        let after = vec![IoCounters {
335            device: "nvme0n1".into(),
336            read: 3_000,
337            write: 2_000,
338        }];
339        let rates = compute_rates(&before, &after, 0.5);
340        assert_eq!(rates.len(), 1);
341        assert_eq!(rates[0].read, 4_000.0);
342        assert_eq!(rates[0].write, 0.0);
343    }
344
345    #[test]
346    fn test_compute_rates_drops_devices_missing_from_either_sample() {
347        let before = vec![IoCounters {
348            device: "eth0".into(),
349            read: 10,
350            write: 10,
351        }];
352        let after = vec![
353            IoCounters {
354                device: "eth0".into(),
355                read: 20,
356                write: 10,
357            },
358            // Appeared mid-run: its lifetime counter is not a delta.
359            IoCounters {
360                device: "wt0".into(),
361                read: 9_999_999,
362                write: 9_999_999,
363            },
364        ];
365        let rates = compute_rates(&before, &after, 1.0);
366        assert_eq!(rates.len(), 1);
367        assert_eq!(rates[0].device, "eth0");
368    }
369
370    #[test]
371    fn test_compute_rates_clamps_a_counter_reset_to_zero() {
372        // An interface going down and up resets its counters; a wrapping subtraction here
373        // renders an exabyte-per-second reading from an ordinary event.
374        //
375        // The clamp reports 0, NOT the post-reset counter. A decrease says the baseline is
376        // void, not how many bytes flowed after it — and 1024 bytes into a window is a
377        // guess that is wrong whenever the decrease had some other cause. Under-reporting
378        // beats asserting something false, the same call as `Users: 0` (v0.6.1) and the
379        // ambiguous input devices (v0.7.0).
380        let before = vec![
381            IoCounters {
382                device: "wlan0".into(),
383                read: 5_000_000,
384                write: 5_000_000,
385            },
386            IoCounters {
387                device: "eth0".into(),
388                read: 1_000,
389                write: 1_000,
390            },
391        ];
392        let after = vec![
393            IoCounters {
394                device: "wlan0".into(),
395                read: 1_024,
396                write: 0,
397            },
398            IoCounters {
399                device: "eth0".into(),
400                read: 3_000,
401                write: 1_000,
402            },
403        ];
404        let rates = compute_rates(&before, &after, 1.0);
405        assert_eq!(rates[0].device, "wlan0");
406        assert_eq!(rates[0].read, 0.0);
407        assert_eq!(rates[0].write, 0.0);
408        // The unaffected device in the same pair must still report, so this test cannot
409        // pass by every rate happening to be zero.
410        assert_eq!(rates[1].device, "eth0");
411        assert_eq!(rates[1].read, 2_000.0);
412    }
413
414    #[test]
415    fn test_compute_rates_refuses_a_zero_or_negative_window() {
416        let sample = vec![IoCounters {
417            device: "nvme0n1".into(),
418            read: 1,
419            write: 1,
420        }];
421        assert!(compute_rates(&sample, &sample, 0.0).is_empty());
422        assert!(compute_rates(&sample, &sample, -1.0).is_empty());
423        assert!(compute_rates(&sample, &sample, f64::NAN).is_empty());
424    }
425
426    #[test]
427    fn test_format_rate_matches_the_net_field_units() {
428        assert_eq!(format_rate(0.0), "0 B/s");
429        assert_eq!(format_rate(512.0), "512 B/s");
430        assert_eq!(format_rate(1024.0), "1.0 KB/s");
431        assert_eq!(format_rate(1024.0 * 1024.0 * 1.5), "1.5 MB/s");
432        // Not reachable from compute_rates, but the formatter is public.
433        assert_eq!(format_rate(f64::NAN), "0 B/s");
434        assert_eq!(format_rate(-1.0), "0 B/s");
435    }
436
437    #[test]
438    fn test_format_io_line() {
439        let rate = IoRate {
440            device: "nvme0n1".into(),
441            read: 0.0,
442            write: 1024.0 * 308.0,
443        };
444        assert_eq!(
445            format_io_line(&rate, "R", "W"),
446            "nvme0n1 R: 0 B/s W: 308.0 KB/s"
447        );
448    }
449
450    #[test]
451    fn test_select_net_rates_prefers_the_active_interface() {
452        let rates = vec![
453            IoRate {
454                device: "wlp0s20f3".into(),
455                read: 100.0,
456                write: 50.0,
457            },
458            IoRate {
459                device: "wt0".into(),
460                read: 10.0,
461                write: 10.0,
462            },
463        ];
464        let selected = select_net_rates(rates, Some("wlp0s20f3"));
465        assert_eq!(selected.len(), 1);
466        assert_eq!(selected[0].device, "wlp0s20f3");
467    }
468
469    #[test]
470    fn test_select_net_rates_keeps_an_idle_active_interface() {
471        // 0 B/s on the interface you are actually using is a reading, not a miss.
472        let rates = vec![IoRate {
473            device: "eth0".into(),
474            read: 0.0,
475            write: 0.0,
476        }];
477        let selected = select_net_rates(rates, Some("eth0"));
478        assert_eq!(selected.len(), 1);
479        assert_eq!(selected[0].device, "eth0");
480    }
481
482    #[test]
483    fn test_select_net_rates_falls_back_to_busy_interfaces() {
484        let rates = vec![
485            IoRate {
486                device: "eth0".into(),
487                read: 0.0,
488                write: 0.0,
489            },
490            IoRate {
491                device: "wt0".into(),
492                read: 1.0,
493                write: 0.0,
494            },
495        ];
496        // Unknown active interface: report what moved, not everything.
497        let selected = select_net_rates(rates.clone(), None);
498        assert_eq!(selected.len(), 1);
499        assert_eq!(selected[0].device, "wt0");
500        // An active interface that is not in the list at all falls back the same way.
501        let selected = select_net_rates(rates, Some("ppp0"));
502        assert_eq!(selected.len(), 1);
503        assert_eq!(selected[0].device, "wt0");
504    }
505}