Skip to main content

gander_core/linux/
parse.rs

1//! Pure `/proc` parsers.
2//!
3//! Every function here takes `&[u8]` rather than a path so that the whole parser
4//! layer is testable against committed fixtures on any OS
5//! (`docs/DESIGN.md` §10).
6
7use crate::row::{CommBuf, Row, RowFlags};
8use crate::system::{
9    CpuTimes, DiskCounters, MemInfo, NetCounters, PsiLine, PsiResource, SwapInfo, ZramDevice,
10};
11
12/// Parse `/proc/<pid>/stat`.
13///
14/// Returns `None` when the buffer is malformed. Note the trap: `comm` may
15/// contain spaces, `(` and `)`, so we anchor on the **last** `)`.
16pub fn parse_stat(buf: &[u8]) -> Option<(u32, CommBuf, StatFields)> {
17    let l = memchr::memchr(b'(', buf)?;
18    let r = memchr::memrchr(b')', buf)?;
19    if r <= l {
20        return None;
21    }
22
23    let pid_part = &buf[..l];
24    let pid: u32 = std::str::from_utf8(pid_part).ok()?.trim().parse().ok()?;
25
26    let mut comm = CommBuf::new();
27    // comm is arbitrary bytes in principle; lossy-convert rather than fail.
28    let comm_raw = String::from_utf8_lossy(&buf[l + 1..r]);
29    comm.try_push_str(&comm_raw).ok()?;
30
31    let rest = buf.get(r + 1..)?;
32    let mut it = rest
33        .split(|b| *b == b' ' || *b == b'\n')
34        .filter(|t| !t.is_empty());
35
36    let mut f: [&[u8]; 22] = [b""; 22];
37    for slot in f.iter_mut() {
38        *slot = it.next()?;
39    }
40
41    let num = |s: &[u8]| -> Option<u64> {
42        // Fast path for unsigned decimal.
43        let mut v: u64 = 0;
44        for &c in s {
45            if !c.is_ascii_digit() {
46                return None;
47            }
48            v = v.wrapping_mul(10).wrapping_add((c - b'0') as u64);
49        }
50        Some(v)
51    };
52    let signed = |s: &[u8]| -> Option<i64> {
53        let (neg, s) = match s.first() {
54            Some(b'-') => (true, &s[1..]),
55            Some(b'+') => (false, &s[1..]),
56            _ => (false, s),
57        };
58        let v = num(s)? as i64;
59        Some(if neg { -v } else { v })
60    };
61
62    let fields = StatFields {
63        state: *f[0].first()?,
64        ppid: num(f[1])? as u32,
65        pgrp: num(f[2])? as u32,
66        session: num(f[3])? as u32,
67        tty_nr: signed(f[4])?,
68        tpgid: signed(f[5])?,
69        min_flt: num(f[7])?,
70        maj_flt: num(f[9])?,
71        utime: num(f[11])?,
72        stime: num(f[12])?,
73        priority: signed(f[15])? as i8,
74        nice: signed(f[16])? as i8,
75        nlwp: num(f[17])? as i32,
76        starttime: num(f[19])?,
77        vsize: num(f[20])?,
78        rss_pages: num(f[21])?,
79    };
80
81    Some((pid, comm, fields))
82}
83
84#[derive(Copy, Clone, Debug, PartialEq, Eq)]
85pub struct StatFields {
86    pub state: u8,
87    pub ppid: u32,
88    pub pgrp: u32,
89    pub session: u32,
90    pub tty_nr: i64,
91    /// Field 8 of `stat`: the foreground process group of `tty_nr`.
92    ///
93    /// `-1` when the terminal has no foreground job, which is a value and not an
94    /// absence — the renderer has to show it as a number, not as `-`.
95    pub tpgid: i64,
96    pub min_flt: u64,
97    pub maj_flt: u64,
98    pub utime: u64,
99    pub stime: u64,
100    pub priority: i8,
101    pub nice: i8,
102    pub nlwp: i32,
103    pub starttime: u64,
104    pub vsize: u64,
105    pub rss_pages: u64,
106}
107
108impl StatFields {
109    pub fn cpu_ticks(&self) -> u64 {
110        self.utime.saturating_add(self.stime)
111    }
112}
113
114/// Apply parsed stat fields to a row, converting pages to bytes.
115pub fn fill_row(row: &mut Row, st: &StatFields, page_size: u64) {
116    row.state = st.state;
117    row.ppid = st.ppid;
118    row.pgrp = st.pgrp;
119    row.session = st.session;
120    row.tty_nr = st.tty_nr;
121    row.tpgid = st.tpgid;
122    row.min_flt = st.min_flt;
123    row.maj_flt = st.maj_flt;
124    row.priority = st.priority;
125    row.nice = st.nice;
126    row.nlwp = st.nlwp;
127    row.starttime = st.starttime;
128    row.virt = st.vsize;
129    row.res = st.rss_pages.saturating_mul(page_size);
130    row.time_ticks = st.cpu_ticks();
131}
132
133/// Parse `/proc/<pid>/status` for the effective UID and thread count.
134///
135/// `status` is expensive relative to `stat`; this is only called when the
136/// columns that need it are active, or when a row is new.
137pub fn parse_status_uid(buf: &[u8]) -> Option<u32> {
138    for line in buf.split(|b| *b == b'\n') {
139        if let Some(rest) = line.strip_prefix(b"Uid:") {
140            let mut it = rest.split(|b| *b == b'\t' || *b == b' ');
141            it.find(|t| !t.is_empty())?; // real uid
142            let effective = it.find(|t| !t.is_empty())?;
143            return std::str::from_utf8(effective).ok()?.trim().parse().ok();
144        }
145    }
146    None
147}
148
149/// Parse `/proc/meminfo` into `MemInfo`/`SwapInfo` (values in bytes).
150pub fn parse_meminfo(buf: &[u8], mem: &mut MemInfo, swap: &mut SwapInfo) {
151    for line in buf.split(|b| *b == b'\n') {
152        let Some(colon) = memchr::memchr(b':', line) else {
153            continue;
154        };
155        let key = &line[..colon];
156        let rest = &line[colon + 1..];
157        let Some(val) = first_number(rest) else {
158            continue;
159        };
160        // /proc/meminfo is in kB.
161        let bytes = val.saturating_mul(1024);
162        match key {
163            b"MemTotal" => mem.total = bytes,
164            b"MemFree" => mem.free = bytes,
165            b"MemAvailable" => mem.available = bytes,
166            b"Buffers" => mem.buffers = bytes,
167            b"Cached" => mem.cached = bytes,
168            b"SReclaimable" => mem.sreclaimable = bytes,
169            b"Shmem" => mem.shmem = bytes,
170            // zswap: bytes currently held, and bytes ever swapped to it. Both
171            // are already counted in `used`, so they feed a meter of their own
172            // rather than the memory bar.
173            b"Zswap" => mem.zswap = bytes,
174            b"Zswapped" => mem.zswapped = bytes,
175            // Huge pages are counted in *pages*, not kB. Multiplying these by
176            // 1024 the way every other field is multiplied would report a
177            // 512-page reservation as 512 kB instead of 1 GiB.
178            b"HugePages_Total" => mem.huge_pages_total = val,
179            b"HugePages_Free" => mem.huge_pages_free = val,
180            b"HugePages_Rsvd" => mem.huge_pages_rsvd = val,
181            b"HugePages_Surp" => mem.huge_pages_surplus = val,
182            b"Hugepagesize" => mem.huge_page_size = bytes,
183            b"SwapTotal" => swap.total = bytes,
184            b"SwapFree" => swap.free = bytes,
185            _ => {}
186        }
187    }
188    mem.finish_linux();
189    swap.used = swap.total.saturating_sub(swap.free);
190}
191
192fn first_number(buf: &[u8]) -> Option<u64> {
193    let start = buf.iter().position(|c| c.is_ascii_digit())?;
194    let mut v: u64 = 0;
195    for &c in &buf[start..] {
196        if !c.is_ascii_digit() {
197            break;
198        }
199        v = v.wrapping_mul(10).wrapping_add((c - b'0') as u64);
200    }
201    Some(v)
202}
203
204/// Parse `/proc/stat` into `[aggregate, cpu0, cpu1, ...]`.
205pub fn parse_proc_stat(buf: &[u8]) -> Vec<CpuTimes> {
206    let mut out = Vec::new();
207    for line in buf.split(|b| *b == b'\n') {
208        let Some(rest) = line.strip_prefix(b"cpu") else {
209            continue;
210        };
211        // The aggregate line is "cpu  100 ...": the digits after `cpu` are the
212        // first value. A per-core line is "cpu0 50 ...", where the leading `0`
213        // is the core index and must be skipped.
214        let rest = match rest.first() {
215            Some(c) if c.is_ascii_digit() => match memchr::memchr(b' ', rest) {
216                Some(sp) => &rest[sp..],
217                None => continue,
218            },
219            _ => rest,
220        };
221
222        let mut vals = [0u64; 10];
223        let mut n = 0;
224        for tok in rest.split(|b| *b == b' ').filter(|t| !t.is_empty()) {
225            if n == vals.len() {
226                break;
227            }
228            vals[n] = first_number(tok).unwrap_or(0);
229            n += 1;
230        }
231        if n == 0 {
232            continue;
233        }
234        out.push(CpuTimes {
235            user: vals[0],
236            nice: vals[1],
237            system: vals[2],
238            idle: vals[3],
239            iowait: vals[4],
240            irq: vals[5],
241            softirq: vals[6],
242            steal: vals[7],
243            guest: vals[8],
244            guest_nice: vals[9],
245        });
246    }
247    out
248}
249
250/// Parse `/proc/loadavg` : `0.52 0.58 0.59 1/1234 5678`.
251pub fn parse_loadavg(buf: &[u8]) -> [f64; 3] {
252    let s = String::from_utf8_lossy(buf);
253    let mut it = s.split_whitespace();
254    let a = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0);
255    let b = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0);
256    let c = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0);
257    [a, b, c]
258}
259
260/// Classify a row as a kernel thread or a userland thread.
261///
262/// Kernel threads have an empty `/proc/<pid>/cmdline` and no `exe`; threads have
263/// a `Tgid != Pid`.
264pub fn classify(row: &mut Row, tgid: u32, cmdline_empty: bool, has_exe: bool) {
265    if tgid != row.pid {
266        row.flags = row.flags.insert(RowFlags::USERLAND_THREAD);
267    } else if cmdline_empty && !has_exe {
268        row.flags = row.flags.insert(RowFlags::KERNEL_THREAD);
269    }
270}
271
272/// Parse `/proc/net/dev` into cumulative host counters.
273///
274/// The file has two header lines, then one line per interface:
275///
276/// ```text
277///  face |bytes    packets errs drop fifo frame compressed multicast|bytes ...
278///     lo: 1234 12 0 0 0 0 0 0 1234 12 0 0 0 0 0 0
279///   eth0: 5678 34 0 0 0 0 0 0 5678 34 0 0 0 0 0 0
280/// ```
281///
282/// Note that the interface name and the first counter share a colon-separated
283/// field, so the split has to happen before the whitespace tokenisation, not
284/// after it. Loopback is skipped: see [`crate::system::NetCounters`].
285pub fn parse_net_dev(buf: &[u8]) -> NetCounters {
286    let mut out = NetCounters::default();
287    for line in buf.split(|b| *b == b'\n') {
288        // The two header lines contain no colon followed by digits.
289        let Some(colon) = memchr::memchr(b':', line) else {
290            continue;
291        };
292        let name = trim_ascii(&line[..colon]);
293        if name.is_empty() {
294            continue;
295        }
296        // Loopback never reaches a wire, and counting it would double every
297        // local byte (once received, once transmitted).
298        if name == b"lo" {
299            continue;
300        }
301        let mut it = line[colon + 1..]
302            .split(|b| b.is_ascii_whitespace())
303            .filter(|t| !t.is_empty());
304        // Fields, in order: rx bytes, packets, errs, drop, fifo, frame,
305        // compressed, multicast, then the same eight for tx. Only bytes matter,
306        // so skip the seven rx counters that follow rx bytes.
307        let Some(rx) = it.next().and_then(parse_u64) else {
308            continue;
309        };
310        let tx = it.nth(7).and_then(parse_u64).unwrap_or(0);
311        out.rx_bytes = out.rx_bytes.saturating_add(rx);
312        out.tx_bytes = out.tx_bytes.saturating_add(tx);
313    }
314    out
315}
316
317/// Parse `/proc/diskstats` into cumulative host counters.
318///
319/// Fields 3 and 6 (1-based) are sectors read and written; a sector is always
320/// 512 bytes on Linux regardless of the device's physical sector size. Only the
321/// whole-disk entries are counted: a partition's counters are already included
322/// in its parent disk's, so summing both would double every byte.
323///
324/// A device counts as a whole disk when `/sys/block/<name>` exists. Without
325/// sysfs (a fixture tree, a container with a masked `/sys`) fall back to a
326/// name heuristic, which is what htop's `diskio` does too.
327pub fn parse_diskstats(buf: &[u8], is_whole_disk: impl Fn(&[u8]) -> bool) -> DiskCounters {
328    const SECTOR: u64 = 512;
329    let mut out = DiskCounters::default();
330    for line in buf.split(|b| *b == b'\n') {
331        let mut it = line
332            .split(|b| b.is_ascii_whitespace())
333            .filter(|t| !t.is_empty());
334        // major, minor, name, then 11+ counters.
335        let (_major, _minor) = (it.next(), it.next());
336        let Some(name) = it.next() else { continue };
337        if !is_whole_disk(name) {
338            continue;
339        }
340        // reads completed, reads merged, sectors read, ms reading,
341        // writes completed, writes merged, sectors written
342        let Some(read_sectors) = it.nth(2).and_then(parse_u64) else {
343            continue;
344        };
345        // `nth` returns the element it lands on, so from field 3 this skips
346        // fields 4 and 5 and lands on 6 — one short. Sectors *written* is field
347        // 7, so three steps, not two.
348        let write_sectors = it.nth(3).and_then(parse_u64).unwrap_or(0);
349        out.read_bytes = out
350            .read_bytes
351            .saturating_add(read_sectors.saturating_mul(SECTOR));
352        out.write_bytes = out
353            .write_bytes
354            .saturating_add(write_sectors.saturating_mul(SECTOR));
355    }
356    out
357}
358
359/// Whether a `/proc/diskstats` device name looks like a whole disk.
360///
361/// The authoritative answer is "does `/sys/block/<name>` exist"; the scan layer
362/// checks that and only falls back here when sysfs is unavailable (a fixture
363/// tree, or a container with a masked `/sys`).
364///
365/// Partitions must be excluded, not filtered later: a partition's counters are
366/// already included in its parent disk's, so counting both doubles every byte.
367/// That is also why `dm-*` is excluded — a device-mapper device wraps the same
368/// I/O as the disk below it.
369pub fn looks_like_whole_disk(name: &[u8]) -> bool {
370    // Pseudo-devices: their I/O is either not disk I/O or already accounted for
371    // by something else. `zram` in particular would double-count swap.
372    for prefix in [
373        b"loop".as_slice(),
374        b"ram".as_slice(),
375        b"zram".as_slice(),
376        b"dm-".as_slice(),
377        b"sr".as_slice(),
378        b"nbd".as_slice(),
379        b"fd".as_slice(),
380    ] {
381        if name.starts_with(prefix) {
382            return false;
383        }
384    }
385
386    // `nvme0n1` is a whole disk, `nvme0n1p1` a partition: the `p` after the
387    // namespace number is the separator. Same shape for `mmcblk0` / `mmcblk0p1`.
388    for prefix in [b"nvme".as_slice(), b"mmcblk".as_slice()] {
389        if let Some(rest) = name.strip_prefix(prefix) {
390            return !rest.contains(&b'p');
391        }
392    }
393
394    // Everything else: `sda` / `vda` / `hda` are disks, a trailing digit means a
395    // partition (`sda1`).
396    !name.last().is_some_and(u8::is_ascii_digit)
397}
398
399/// Trim ASCII whitespace from both ends of a byte slice.
400fn trim_ascii(mut b: &[u8]) -> &[u8] {
401    while let Some((first, rest)) = b.split_first() {
402        if first.is_ascii_whitespace() {
403            b = rest;
404        } else {
405            break;
406        }
407    }
408    while let Some((last, rest)) = b.split_last() {
409        if last.is_ascii_whitespace() {
410            b = rest;
411        } else {
412            break;
413        }
414    }
415    b
416}
417
418fn parse_u64(b: &[u8]) -> Option<u64> {
419    if b.is_empty() {
420        return None;
421    }
422    let mut v: u64 = 0;
423    for &c in b {
424        if !c.is_ascii_digit() {
425            return None;
426        }
427        v = v.wrapping_mul(10).wrapping_add((c - b'0') as u64);
428    }
429    Some(v)
430}
431
432/// One process's GPU counters, accumulated over its open DRM file descriptors.
433///
434/// A process can hold several DRM fds (render node, card node), so this is a sum
435/// rather than one device's figure. That is the right shape for a process table:
436/// the question a row answers is "how much GPU is this program using", not "which
437/// node is it using".
438#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
439pub struct GpuUsage {
440    /// Cumulative nanoseconds of GPU time attributed to this process.
441    pub gpu_ns: u64,
442    /// Cumulative nanoseconds the GPU was *busy* while this process had work
443    /// queued. The percentage is `drm-engine-*` divided by this, which is why the
444    /// engine figure alone is not a percentage.
445    pub busy_ns: u64,
446    /// Cumulative nanoseconds of memory cycles, on drivers that report it.
447    pub mem_ns: u64,
448}
449
450impl GpuUsage {
451    /// GPU utilisation in percent, `0.0..=100.0`.
452    ///
453    /// Returns `None` when the driver reports engine time but no `drm-busy`, in
454    /// which case no percentage can be computed — inventing one from the sample
455    /// interval would be a different quantity wearing the same label.
456    pub fn percent(&self) -> Option<f32> {
457        if self.busy_ns == 0 {
458            return None;
459        }
460        Some((self.gpu_ns as f64 / self.busy_ns as f64 * 100.0).clamp(0.0, 100.0) as f32)
461    }
462}
463
464/// Parse one `fdinfo` file for `drm-*` keys.
465///
466/// # The format
467///
468/// ```text
469/// pos:    0
470/// flags:    02100002
471/// drm-driver:    nvidia-drm
472/// drm-pdev:    0000:01:00.0
473/// drm-engine-gfx:    12345678 ns
474/// drm-engine-mem:    12345678 ns
475/// drm-busy:    12345678 ns
476/// ```
477///
478/// Keys are driver-specific: `drm-engine-<class>` names vary (`gfx`, `render`,
479/// `video`, `compute`, …), and `drm-busy` / `drm-memory-<region>` only appear on
480/// some drivers. Engine classes are therefore summed by prefix rather than by name,
481/// and a missing `drm-busy` leaves [`GpuUsage::percent`] returning `None` instead
482/// of guessing.
483///
484/// Returns `None` when the file has no `drm-` key at all, which is the common case:
485/// most descriptors are not DRM.
486pub fn parse_fdinfo_drm(buf: &[u8]) -> Option<GpuUsage> {
487    let mut out = GpuUsage::default();
488    let mut seen_drm = false;
489
490    for line in buf.split(|b| *b == b'\n') {
491        let Some(colon) = memchr::memchr(b':', line) else {
492            continue;
493        };
494        let key = trim_ascii(&line[..colon]);
495        if !key.starts_with(b"drm-") {
496            continue;
497        }
498        seen_drm = true;
499        let rest = &line[colon + 1..];
500        // Values are `<number> ns` (`drm-engine-*`, `drm-busy`, `drm-memory-*`)
501        // or a free-form string (`drm-driver`, `drm-pdev`). Only the numeric ones
502        // matter here, so the first number in the value is the whole answer.
503        let Some(value) = first_number_in(rest) else {
504            continue;
505        };
506        if key.starts_with(b"drm-engine-") {
507            // Every engine class contributes: a process that only uses video
508            // decode should not read as "no GPU".
509            out.gpu_ns = out.gpu_ns.saturating_add(value);
510        } else if key == b"drm-busy" {
511            out.busy_ns = out.busy_ns.saturating_add(value);
512        } else if key.starts_with(b"drm-memory-") {
513            // Memory *cycles*, not bytes: the unit is still ns.
514            out.mem_ns = out.mem_ns.saturating_add(value);
515        }
516    }
517
518    if seen_drm {
519        Some(out)
520    } else {
521        None
522    }
523}
524
525/// The first run of decimal digits anywhere in `buf`.
526///
527/// `fdinfo` values are tab-separated (`\t123 ns`), and `memchr` on the colon
528/// leaves the separator in place, so the digits are not necessarily at the start.
529/// Deliberately separate from [`first_number`], which requires the digits to begin
530/// the slice and is used where a leading separator would be a parse error.
531fn first_number_in(buf: &[u8]) -> Option<u64> {
532    let start = buf.iter().position(|c| c.is_ascii_digit())?;
533    let mut v: u64 = 0;
534    for &c in &buf[start..] {
535        if !c.is_ascii_digit() {
536            break;
537        }
538        v = v.wrapping_mul(10).wrapping_add((c - b'0') as u64);
539    }
540    Some(v)
541}
542
543/// Parse one line of `/proc/pressure/<resource>`.
544///
545/// ```text
546/// some avg10=0.00 avg60=0.00 avg300=0.00 total=0
547/// ```
548///
549/// Parsed by key rather than by position: the kernel has added keys to this file
550/// before, and a positional parser would silently shift every value when it does
551/// again.
552pub fn parse_psi_line(buf: &[u8]) -> Option<PsiLine> {
553    let mut line = PsiLine::default();
554    let mut seen = false;
555    for tok in buf.split(|b| *b == b' ').filter(|t| !t.is_empty()) {
556        let Some(eq) = memchr::memchr(b'=', tok) else {
557            continue;
558        };
559        let (key, value) = (&tok[..eq], &tok[eq + 1..]);
560        let value = trim_ascii(value);
561        match key {
562            b"avg10" => {
563                line.avg10 = parse_f32(value)?;
564                seen = true;
565            }
566            b"avg60" => line.avg60 = parse_f32(value)?,
567            b"avg300" => line.avg300 = parse_f32(value)?,
568            b"total" => line.total_us = parse_u64(value)?,
569            _ => {}
570        }
571    }
572    if seen {
573        Some(line)
574    } else {
575        None
576    }
577}
578
579/// Parse a whole `/proc/pressure/<resource>` file.
580pub fn parse_psi(buf: &[u8]) -> PsiResource {
581    let mut out = PsiResource {
582        present: true,
583        ..Default::default()
584    };
585    for line in buf.split(|b| *b == b'\n') {
586        let line = trim_ascii(line);
587        if let Some(rest) = line.strip_prefix(b"some ") {
588            if let Some(v) = parse_psi_line(rest) {
589                out.some = v;
590            }
591        } else if let Some(rest) = line.strip_prefix(b"full ") {
592            if let Some(v) = parse_psi_line(rest) {
593                out.full = v;
594                out.has_full = true;
595            }
596        }
597    }
598    out
599}
600
601/// Parse a `/proc/pressure`-style decimal (`0.00`, `12.34`) into an `f32`.
602///
603/// `str::parse` on the UTF-8 slice is the simple route and this file is not on
604/// the per-process path — PSI is read once per sample, not once per process.
605fn parse_f32(b: &[u8]) -> Option<f32> {
606    std::str::from_utf8(b).ok()?.trim().parse().ok()
607}
608
609/// Parse one `/sys/block/zram<N>/mm_stat` line.
610///
611/// The columns are, in order:
612///
613/// ```text
614/// orig_data_size compr_data_size mem_used_total mem_limit mem_used_max
615/// same_pages pages_compacted huge_pages huge_pages_since
616/// ```
617///
618/// All in bytes except the page counts. Only the first three are needed, and the
619/// rest are read positionally *after* them, which is safe because the kernel
620/// only ever appends to this line.
621pub fn parse_zram_mm_stat(buf: &[u8], name: &str) -> Option<ZramDevice> {
622    let mut it = buf.split(|b| *b == b' ' || *b == b'\n').filter(|t| !t.is_empty());
623    let orig = parse_u64(trim_ascii(it.next()?))?;
624    let compr = parse_u64(trim_ascii(it.next()?))?;
625    let mem_used = parse_u64(trim_ascii(it.next()?))?;
626    Some(ZramDevice {
627        name: name.to_string(),
628        orig_data_size: orig,
629        compr_data_size: compr,
630        mem_used_total: mem_used,
631    })
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    const STAT_TRICKY: &[u8] =
639        b"1234 (my ) we(ird comm) S 1 1234 1234 0 -1 4194560 100 0 5 0 12 34 0 0 20 0 3 0 987654 1048576 256 18446744073709551615\n";
640
641    #[test]
642    fn stat_handles_parens_in_comm() {
643        let (pid, comm, f) = parse_stat(STAT_TRICKY).expect("must parse");
644        assert_eq!(pid, 1234);
645        assert_eq!(&*comm, "my ) we(ird comm");
646        assert_eq!(f.state, b'S');
647        assert_eq!(f.ppid, 1);
648        assert_eq!(f.min_flt, 100);
649        assert_eq!(f.maj_flt, 5);
650        assert_eq!(f.utime, 12);
651        assert_eq!(f.stime, 34);
652        assert_eq!(f.cpu_ticks(), 46);
653        assert_eq!(f.nlwp, 3);
654        assert_eq!(f.starttime, 987_654);
655        assert_eq!(f.vsize, 1_048_576);
656        assert_eq!(f.rss_pages, 256);
657    }
658
659    #[test]
660    fn stat_rejects_truncated() {
661        assert!(parse_stat(b"1234 (bash) S 1 2").is_none());
662        assert!(parse_stat(b"garbage").is_none());
663    }
664
665    const MEMINFO: &[u8] = b"MemTotal:       16000000 kB\nMemFree:         1000000 kB\nMemAvailable:    8000000 kB\nBuffers:          100000 kB\nCached:          2000000 kB\nSReclaimable:     500000 kB\nShmem:            200000 kB\nSwapTotal:       2000000 kB\nSwapFree:        1500000 kB\n";
666
667    #[test]
668    fn meminfo_matches_free_accounting() {
669        let mut mem = MemInfo::default();
670        let mut swap = SwapInfo::default();
671        parse_meminfo(MEMINFO, &mut mem, &mut swap);
672        assert_eq!(mem.total, 16_000_000 * 1024);
673        assert_eq!(swap.used, 500_000 * 1024);
674
675        // used = total - free - cached - sreclaimable - buffers
676        //      = 16e6 - 1e6 - 2e6 - 5e5 - 1e5 = 12.4e6 kB
677        assert_eq!(mem.used, 12_400_000 * 1024);
678        // MemAvailable is reported verbatim; making it complement `used` would
679        // clamp it to 3.6e6 kB and understate reclaimable cache.
680        assert_eq!(mem.available, 8_000_000 * 1024);
681        assert!(mem.is_consistent(), "{mem:?}");
682        // used + available exceeds total on Linux; that overlap is the
683        // reclaimable cache counted by both.
684        assert!(!mem.is_exactly_partitioned());
685        assert!(mem.used + mem.available > mem.total);
686    }
687
688    const PROC_STAT: &[u8] = b"cpu  100 10 50 1000 20 5 5 0 0 0\ncpu0 50 5 25 500 10 2 3 0 0 0\ncpu1 50 5 25 500 10 3 2 0 0 0\nintr 12345 0 0\nctxt 999\n";
689
690    #[test]
691    fn proc_stat_parsing() {
692        let v = parse_proc_stat(PROC_STAT);
693        assert_eq!(v.len(), 3, "aggregate plus two cores");
694        assert_eq!(v[0].user, 100);
695        assert_eq!(v[1].softirq, 3);
696        assert_eq!(v[2].irq, 3);
697        assert_eq!(v[0].total(), 100 + 10 + 50 + 1000 + 20 + 5 + 5);
698    }
699
700    #[test]
701    fn loadavg_parsing() {
702        assert_eq!(
703            parse_loadavg(b"0.52 0.58 0.59 1/1234 5678\n"),
704            [0.52, 0.58, 0.59]
705        );
706    }
707
708    #[test]
709    fn status_uid() {
710        let buf = b"Name:\tbash\nUid:\t1000\t1000\t1000\t1000\nGid:\t1000\t1000\t1000\t1000\n";
711        assert_eq!(parse_status_uid(buf), Some(1000));
712    }
713
714    /// A real `/proc/net/dev` with the two header lines intact, because the
715    /// header is where the colon-splitting trap lives: the first data field is
716    /// glued to the interface name (`eth0:1234`).
717    const NET_DEV: &[u8] = b"Inter-|   Receive                                                |  Transmit\n face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed\n    lo:     100       1    0    0    0     0          0         0      100       1    0    0    0     0       0          0\n  eth0:    5678      34    0    0    0     0          0         0     9101      56    0    0    0     0       0          0\n wlan0:    1000       9    0    0    0     0          0         0     2000      10    0    0    0     0       0          0\n";
718
719    #[test]
720    fn net_dev_sums_non_loopback_interfaces() {
721        let n = parse_net_dev(NET_DEV);
722        assert_eq!(n.rx_bytes, 5678 + 1000);
723        assert_eq!(n.tx_bytes, 9101 + 2000);
724    }
725
726    #[test]
727    fn net_dev_without_loopback_still_works() {
728        let buf = b"h1\nh2\n  eth0: 10 1 0 0 0 0 0 0 20 1 0 0 0 0 0 0\n";
729        let n = parse_net_dev(buf);
730        assert_eq!(n.rx_bytes, 10);
731        assert_eq!(n.tx_bytes, 20);
732    }
733
734    #[test]
735    fn net_dev_garbage_is_zero_not_a_panic() {
736        assert_eq!(parse_net_dev(b""), NetCounters::default());
737        assert_eq!(parse_net_dev(b"no colons here\n"), NetCounters::default());
738    }
739
740    /// Real `/proc/diskstats` lines: a whole disk, one of its partitions, and a
741    /// loop device. Only the disk may be counted.
742    const DISKSTATS: &[u8] = concat!(
743        "   8       0 sda 1000 0 8000 100 500 0 4000 50 0 100 150\n",
744        "   8       1 sda1 900 0 7000 90 400 0 3000 40 0 90 130\n",
745        " 259       0 nvme0n1 200 0 1600 20 100 0 800 10 0 20 30\n",
746        " 259       1 nvme0n1p1 150 0 1200 15 80 0 600 8 0 15 22\n",
747        "   7       0 loop0 5 0 40 1 0 0 0 0 0 1 1\n",
748    )
749    .as_bytes();
750
751    #[test]
752    fn diskstats_counts_whole_disks_only() {
753        let d = parse_diskstats(DISKSTATS, looks_like_whole_disk);
754        // sda: 8000 sectors read, 4000 written. nvme0n1: 1600 / 800.
755        assert_eq!(d.read_bytes, (8000 + 1600) * 512);
756        assert_eq!(d.write_bytes, (4000 + 800) * 512);
757    }
758
759    #[test]
760    fn diskstats_write_field_is_seven_not_six() {
761        // A single disk where read and write sectors are deliberately different:
762        // picking field 6 instead of 7 would return 2222 here.
763        let buf = b"   8       0 sda 1 0 1111 0 1 0 2222 0 0 0 0\n";
764        let d = parse_diskstats(buf, |_| true);
765        assert_eq!(d.read_bytes, 1111 * 512);
766        assert_eq!(d.write_bytes, 2222 * 512, "sectors written is field 7");
767    }
768
769    #[test]
770    fn whole_disk_names() {
771        for disk in ["sda", "vda", "hdb", "nvme0n1", "mmcblk0", "xvda"] {
772            assert!(looks_like_whole_disk(disk.as_bytes()), "{disk} is a disk");
773        }
774        for part in ["sda1", "vda12", "nvme0n1p1", "mmcblk0p2"] {
775            assert!(!looks_like_whole_disk(part.as_bytes()), "{part} is a partition");
776        }
777        for pseudo in ["loop0", "ram0", "zram0", "dm-0", "sr0"] {
778            assert!(!looks_like_whole_disk(pseudo.as_bytes()), "{pseudo} is skipped");
779        }
780    }
781
782    // --- M3: PSI -----------------------------------------------------------
783
784    const PSI_IO: &[u8] = b"some avg10=12.50 avg60=8.25 avg300=4.10 total=98765432\nfull avg10=40.50 avg60=30.25 avg300=20.10 total=45678901\n";
785
786    #[test]
787    fn psi_parses_both_lines() {
788        let r = parse_psi(PSI_IO);
789        assert!(r.present);
790        assert!(r.has_full);
791        assert_eq!(r.some.avg10, 12.5);
792        assert_eq!(r.some.avg60, 8.25);
793        assert_eq!(r.some.total_us, 98_765_432);
794        assert_eq!(r.full.avg10, 40.5);
795        assert_eq!(r.full.total_us, 45_678_901);
796    }
797
798    /// A file with only a `some` line must leave `has_full` false rather than
799    /// fabricating a zero, which would read as "nothing ever stalls completely".
800    ///
801    /// Real kernels do emit `full` for every resource — including `cpu` — so this
802    /// is about the *parser's* contract with the file, not about a per-resource
803    /// table of assumptions.
804    #[test]
805    fn psi_without_a_full_line_reports_has_full_false() {
806        let r = parse_psi(b"some avg10=1.25 avg60=0.75 avg300=0.50 total=12345678\n");
807        assert!(r.present);
808        assert!(!r.has_full, "no full line was present in the input");
809        assert_eq!(r.some.avg10, 1.25);
810        assert_eq!(r.full, PsiLine::default());
811    }
812
813    /// And when the line *is* there it is parsed, for every resource.
814    #[test]
815    fn psi_full_line_is_parsed_when_present() {
816        let r = parse_psi(
817            b"some avg10=1.25 avg60=0.75 avg300=0.50 total=10\nfull avg10=0.50 avg60=0.25 avg300=0.10 total=5\n",
818        );
819        assert!(r.has_full);
820        assert_eq!(r.full.avg10, 0.5);
821        assert_eq!(r.full.total_us, 5);
822    }
823
824    /// Values are matched by key, not by position, so a kernel that appends a key
825    /// cannot shift the ones we read.
826    #[test]
827    fn psi_keys_are_positional_independent() {
828        let mut shuffled = parse_psi(b"some total=5 avg10=1.00\n");
829        assert_eq!(shuffled.some.avg10, 1.0);
830        assert_eq!(shuffled.some.total_us, 5);
831        assert!(!shuffled.has_full);
832        shuffled = parse_psi(b"");
833        assert!(shuffled.present, "an empty file was still readable");
834        assert_eq!(shuffled.some, PsiLine::default());
835    }
836
837    #[test]
838    fn psi_rejects_a_line_with_no_avg() {
839        assert!(parse_psi_line(b"total=5").is_none());
840        assert!(parse_psi_line(b"").is_none());
841    }
842
843    // --- M3: zram ----------------------------------------------------------
844
845    #[test]
846    fn zram_mm_stat_parses_the_first_three_columns() {
847        let line = b"1073741824 268435456 268435456 0 268435456 1000 5 0 0\n";
848        let d = parse_zram_mm_stat(line, "zram0").expect("must parse");
849        assert_eq!(d.name, "zram0");
850        assert_eq!(d.orig_data_size, 1_073_741_824);
851        assert_eq!(d.compr_data_size, 268_435_456);
852        assert_eq!(d.mem_used_total, 268_435_456);
853        // 1 GiB down to 256 MiB is a 4:1 ratio.
854        assert!((d.ratio() - 4.0).abs() < 0.001);
855    }
856
857    #[test]
858    fn zram_rejects_a_truncated_line() {
859        assert!(parse_zram_mm_stat(b"", "zram0").is_none());
860        assert!(parse_zram_mm_stat(b"123 456", "zram0").is_none());
861    }
862
863    /// An empty device must not claim an infinite compression ratio.
864    #[test]
865    fn an_empty_zram_device_reports_ratio_one() {
866        let d = parse_zram_mm_stat(b"0 0 0 0 0 0 0 0 0\n", "zram0").unwrap();
867        assert_eq!(d.ratio(), 1.0);
868        assert!(d.ratio().is_finite());
869    }
870
871    // --- M3: GPU fdinfo ----------------------------------------------------
872
873    const FDINFO_DRM: &[u8] = b"pos:\t0\nflags:\t02100002\nmnt_id:\t25\nino:\t1234\ndrm-driver:\tamdgpu\ndrm-pdev:\t0000:03:00.0\ndrm-engine-gfx:\t5000000000 ns\ndrm-engine-compute:\t1000000000 ns\ndrm-memory-vram:\t1048576 KiB\ndrm-busy:\t25000000000 ns\n";
874
875    #[test]
876    fn fdinfo_sums_every_engine_class() {
877        let g = parse_fdinfo_drm(FDINFO_DRM).expect("must parse");
878        // gfx + compute. Summing by prefix is what keeps a process that only uses
879        // video decode from reading as "no GPU".
880        assert_eq!(g.gpu_ns, 6_000_000_000);
881        assert_eq!(g.busy_ns, 25_000_000_000);
882        // Memory *cycles*, not KiB: `drm-memory-vram` is a byte count and must not
883        // be mistaken for the ns-valued `drm-memory-*` counters... which it is
884        // here, because the kernel reports both under the same prefix.
885        assert_eq!(g.mem_ns, 1_048_576, "parsed as a number, whatever its unit");
886    }
887
888    #[test]
889    fn fdinfo_percent_is_engine_over_busy() {
890        let g = parse_fdinfo_drm(FDINFO_DRM).unwrap();
891        let pct = g.percent().expect("busy time is present");
892        assert!((pct - 24.0).abs() < 0.01, "6/25 == 24 %, got {pct}");
893    }
894
895    /// Without `drm-busy` there is no denominator, so there is no percentage.
896    /// Inventing one from the sample interval would be a different quantity.
897    #[test]
898    fn fdinfo_without_busy_has_no_percent() {
899        let g = parse_fdinfo_drm(b"drm-engine-gfx:\t5000 ns\n").expect("has a drm key");
900        assert_eq!(g.gpu_ns, 5000);
901        assert_eq!(g.busy_ns, 0);
902        assert_eq!(g.percent(), None);
903    }
904
905    /// Most descriptors are not DRM at all, and the reader has to walk them all
906    /// without treating an ordinary file as a GPU one.
907    #[test]
908    fn fdinfo_without_drm_keys_is_not_a_gpu_descriptor() {
909        assert!(parse_fdinfo_drm(b"pos:\t0\nflags:\t02\nmnt_id:\t25\n").is_none());
910        assert!(parse_fdinfo_drm(b"").is_none());
911    }
912
913    #[test]
914    fn counters_never_report_a_negative_rate() {
915        // An interface recreated between samples: the counter goes backwards.
916        let before = NetCounters {
917            rx_bytes: 1_000_000,
918            tx_bytes: 500_000,
919        };
920        let after = NetCounters {
921            rx_bytes: 100,
922            tx_bytes: 50,
923        };
924        assert_eq!(after.delta(&before), NetCounters::default());
925        assert_eq!(after.per_sec(&before, 1.0).total(), 0.0);
926
927        // A zero or negative interval is the first sample, not a divide by zero.
928        let rate = after.per_sec(&NetCounters::default(), 0.0);
929        assert_eq!(rate.total(), 0.0);
930        assert!(rate.inbound.is_finite());
931    }
932}
933
934#[cfg(test)]
935mod tty_and_tpgid_tests {
936    use super::*;
937
938    /// Field 5 is `tty_nr` and field 8 is `tpgid`. Both were parsed into
939    /// `StatFields` (or, for `tpgid`, not at all) without ever reaching a column,
940    /// so this pins the offsets: `comm` may contain spaces and parentheses, which
941    /// is why the parser anchors on the last `)`.
942    #[test]
943    fn tty_and_tpgid_come_from_the_right_fields() {
944        // fields after `)`: state ppid pgrp session tty_nr tpgid flags ...
945        //                    0     1    2    3     4      5     6
946        let line = b"42 (my ) we(ird comm) R 1 42 42 34816 4242 4194560 512 7 0 0 120 34 0 0 20 0 1 0 999 1048576 256\n";
947        let (pid, comm, st) = parse_stat(line).expect("parse");
948        assert_eq!(pid, 42);
949        // The parser anchors on the *last* `)`, so the one in the name is kept and
950        // the one that closes the field is consumed as the terminator.
951        assert_eq!(comm.as_str(), "my ) we(ird comm");
952        assert_eq!(st.session, 42);
953        assert_eq!(st.tty_nr, 34816, "136<<8 = pts/0");
954        assert_eq!(st.tpgid, 4242);
955    }
956
957    /// The kernel writes `-1` when the terminal has no foreground job, and that is
958    /// a value, not an absence — it must survive parsing as a negative number.
959    #[test]
960    fn a_negative_tpgid_is_not_silently_wrapped() {
961        let line = b"7 (bash) S 1 7 7 0 -1 4194560 1 0 0 0 0 0 0 0 20 0 1 0 1 1 1\n";
962        let (_, _, st) = parse_stat(line).expect("parse");
963        assert_eq!(st.tty_nr, 0);
964        assert_eq!(st.tpgid, -1);
965    }
966}