Skip to main content

gander_core/linux/
scan.rs

1//! Reading `/proc` and `/sys`.
2//!
3//! All hot-path reads go through `thread_local!` scratch buffers so a scan
4//! allocates nothing per process (`docs/DESIGN.md` §9.4).
5
6use std::cell::RefCell;
7use std::fs;
8use std::io;
9use std::os::unix::ffi::OsStrExt;
10use std::path::{Path, PathBuf};
11
12use crate::linux::parse::GpuUsage;
13use crate::row::Row;
14use crate::table::{Cache, Stable};
15use crate::Pid;
16
17thread_local! {
18    static BUF: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(4096));
19    static PATH: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(64));
20}
21
22/// Read a whole file into the thread-local buffer, returning its length.
23///
24/// `ENOENT` is the common case (the process exited between `readdir` and now),
25/// so it is reported as `None` rather than as an error.
26fn read_into_buf(path: &[u8]) -> Option<usize> {
27    let mut file = fs::File::open(std::ffi::OsStr::from_bytes(path)).ok()?;
28    BUF.with(|b| {
29        let mut b = b.borrow_mut();
30        b.clear();
31        use std::io::Read as _;
32        file.read_to_end(&mut b).ok()
33    })
34}
35
36/// Run `f` with the contents of `path`, using the shared scratch buffer.
37fn with_file<R>(path: &[u8], f: impl FnOnce(&[u8]) -> R) -> Option<R> {
38    let n = read_into_buf(path)?;
39    BUF.with(|b| {
40        let b = b.borrow();
41        Some(f(&b[..n]))
42    })
43}
44
45/// Build `"<root>/<pid>/<suffix>"` into the thread-local path buffer and run `f`
46/// with the resulting path.
47fn proc_path(root: &Path, pid: Pid, suffix: &str, f: impl FnOnce(&[u8])) {
48    PATH.with(|p| {
49        let mut p = p.borrow_mut();
50        p.clear();
51        p.extend_from_slice(root.as_os_str().as_bytes());
52        p.push(b'/');
53        write_pid(&mut p, pid);
54        if !suffix.is_empty() {
55            p.push(b'/');
56            p.extend_from_slice(suffix.as_bytes());
57        }
58        f(&p);
59    })
60}
61
62fn write_pid(out: &mut Vec<u8>, mut pid: Pid) {
63    let mut tmp = [0u8; 10];
64    let mut i = tmp.len();
65    if pid == 0 {
66        out.push(b'0');
67        return;
68    }
69    while pid > 0 {
70        i -= 1;
71        tmp[i] = b'0' + (pid % 10) as u8;
72        pid /= 10;
73    }
74    out.extend_from_slice(&tmp[i..]);
75}
76
77/// List process IDs by scanning `/proc` for numeric directory entries.
78pub fn list_pids(root: &Path) -> io::Result<Vec<Pid>> {
79    let mut out = Vec::with_capacity(512);
80    for entry in fs::read_dir(root)? {
81        let Ok(entry) = entry else { continue };
82        let name = entry.file_name();
83        let bytes = name.as_bytes();
84        if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) {
85            continue;
86        }
87        if let Ok(pid) = name.to_string_lossy().parse::<Pid>() {
88            out.push(pid);
89        }
90    }
91    Ok(out)
92}
93
94/// List the thread ids inside a process (`/proc/<pid>/task`).
95///
96/// A process is its own thread group leader, so the leader's tid equals its pid
97/// and appears in this list too; callers filter or keep it deliberately.
98pub fn list_tids(root: &Path, pid: Pid) -> io::Result<Vec<Pid>> {
99    let mut out = Vec::with_capacity(8);
100    let dir = root.join(pid.to_string()).join("task");
101    for entry in fs::read_dir(dir)? {
102        let Ok(entry) = entry else { continue };
103        let name = entry.file_name();
104        let bytes = name.as_bytes();
105        if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) {
106            continue;
107        }
108        if let Ok(tid) = name.to_string_lossy().parse::<Pid>() {
109            out.push(tid);
110        }
111    }
112    Ok(out)
113}
114
115/// Read `stat` for one thread of `pid`.
116///
117/// `/proc/<pid>/task/<tid>/stat` is the same format as a process's `stat`, with
118/// the thread's own CPU counters. The identity files (`status`, `cmdline`) are
119/// per-process, so a thread row reuses its leader's.
120pub fn read_thread_stat(
121    root: &Path,
122    pid: Pid,
123    tid: Pid,
124) -> Option<(crate::row::CommBuf, super::parse::StatFields)> {
125    let path = root
126        .join(pid.to_string())
127        .join("task")
128        .join(tid.to_string())
129        .join("stat");
130    let bytes = fs::read(path).ok()?;
131    let (_pid, comm, fields) = super::parse::parse_stat(&bytes)?;
132    Some((comm, fields))
133}
134
135/// Read the `stat` file of one process.
136pub fn read_stat(root: &Path, pid: Pid) -> Option<(crate::row::CommBuf, super::parse::StatFields)> {
137    let mut slot = None;
138    proc_path(root, pid, "stat", |p| {
139        slot = with_file(p, super::parse::parse_stat).flatten();
140    });
141    let (_pid, comm, fields) = slot?;
142    Some((comm, fields))
143}
144
145/// Join a NUL-separated `cmdline` into a displayable single line.
146fn join_argv(b: &[u8]) -> String {
147    let mut s = String::with_capacity(b.len());
148    let mut first = true;
149    for part in b.split(|c| *c == 0) {
150        if part.is_empty() {
151            continue;
152        }
153        if !first {
154            s.push(' ');
155        }
156        s.push_str(&String::from_utf8_lossy(part));
157        first = false;
158    }
159    s
160}
161
162/// Apply `stat` fields to a row.
163pub fn apply_stat(out: &mut Row, fields: &super::parse::StatFields, page_size: u64) {
164    super::parse::fill_row(out, fields, page_size);
165}
166
167/// Fetch (and cache) the immutable identity of one process.
168pub fn read_identity(root: &Path, pid: Pid, starttime: u64, cache: &mut Cache) -> Stable {
169    if let Some(hit) = cache.get(pid, starttime) {
170        return hit.clone();
171    }
172
173    let mut stable = Stable::default();
174
175    proc_path(root, pid, "status", |p| {
176        if let Some(uid) = with_file(p, super::parse::parse_status_uid).flatten() {
177            stable.uid = uid;
178        }
179    });
180
181    let mut cmdline = None;
182    proc_path(root, pid, "cmdline", |p| {
183        cmdline = with_file(p, join_argv).filter(|s| !s.is_empty());
184    });
185    stable.cmdline = cmdline.map(String::into_boxed_str);
186
187    let mut exe = None;
188    let mut exe_deleted = false;
189    proc_path(root, pid, "exe", |p| {
190        if let Ok(target) = fs::read_link(std::ffi::OsStr::from_bytes(p)) {
191            let text = target.to_string_lossy();
192            // The kernel appends " (deleted)" to the link target once the file
193            // backing a running process is unlinked or replaced. This is the only
194            // available signal: an mtime comparison needs the path to still
195            // exist, which is precisely what deleting it removes.
196            exe_deleted = text.ends_with(" (deleted)");
197            let path = text.strip_suffix(" (deleted)").unwrap_or(&text);
198            exe = Some(path.to_string().into_boxed_str());
199        }
200    });
201    stable.exe = exe;
202    stable.exe_deleted = exe_deleted;
203
204    // The working directory. A `readlink` like `exe`, and readable for the same
205    // processes (the link is only resolvable by the owner or CAP_SYS_PTRACE), so a
206    // failure is another user's process and yields `None`, which renders `-`.
207    proc_path(root, pid, "cwd", |p| {
208        if let Ok(target) = fs::read_link(std::ffi::OsStr::from_bytes(p)) {
209            stable.cwd = Some(target.to_string_lossy().into_owned().into_boxed_str());
210        }
211    });
212
213    // Session id is not part of the identity cache on Linux: it arrives in field 4
214    // of `stat`, which the scan already parses, so `fill_row` sets it. The `stable`
215    // field exists for macOS, where `getsid` is a syscall of its own.
216
217    let mut cgroup = None;
218    #[cfg(target_os = "linux")]
219    let mut container = None;
220    #[cfg(target_os = "linux")]
221    let mut container_runtime = None;
222    proc_path(root, pid, "cgroup", |p| {
223        if let Some(entries) = with_file(p, super::fsops::parse_cgroup) {
224            // The path, not the raw line: `12:cpuset:/docker/abc` carries the
225            // hierarchy id and controller list, which no column wants.
226            cgroup = super::fsops::preferred_path(&entries)
227                .map(|s| s.to_string().into_boxed_str());
228            #[cfg(target_os = "linux")]
229            if let Some(c) = super::fsops::container_from_cgroup(&entries) {
230                container = Some(c.id.into_boxed_str());
231                container_runtime = Some(c.runtime.to_string().into_boxed_str());
232            }
233        }
234    });
235    stable.cgroup = cgroup;
236
237    // A container with its own cgroup namespace reports `/` in
238    // `/proc/<pid>/cgroup`, so the path above says nothing. The PID namespace
239    // still distinguishes a *child* container, and without this the difference
240    // between "not containerised" and "containerised but unidentified" is lost.
241    //
242    // Only consulted when the path did not already identify a container: the path
243    // is the *informative* signal (it names the runtime and the id), the namespace
244    // is the *membership* signal, and the path wins when both exist.
245    //
246    // The id is left as `?` rather than filled with something we made up. A
247    // namespace inode is not a container id, and printing one would look like an
248    // identifier while telling the reader nothing they can act on.
249    #[cfg(target_os = "linux")]
250    if container.is_none()
251        && matches!(
252            super::fsops::in_different_pid_namespace(root, pid),
253            Some(true)
254        )
255    {
256        container = Some("?".to_string().into_boxed_str());
257        container_runtime = Some("container".to_string().into_boxed_str());
258    }
259
260    #[cfg(target_os = "linux")]
261    {
262        stable.container = container;
263        stable.container_runtime = container_runtime;
264    }
265
266    stable.is_kernel_thread = stable.cmdline.is_none() && stable.exe.is_none();
267
268    cache.insert(pid, starttime, stable.clone());
269    stable
270}
271
272/// Read `/proc/meminfo`.
273pub fn read_meminfo(
274    root: &Path,
275    mem: &mut crate::system::MemInfo,
276    swap: &mut crate::system::SwapInfo,
277) {
278    let mut path = PathBuf::from(root);
279    path.push("meminfo");
280    with_file(path.as_os_str().as_bytes(), |b| {
281        super::parse::parse_meminfo(b, mem, swap)
282    });
283}
284
285/// Read `/proc/stat`.
286pub fn read_proc_stat(root: &Path) -> Vec<crate::system::CpuTimes> {
287    let mut path = PathBuf::from(root);
288    path.push("stat");
289    with_file(path.as_os_str().as_bytes(), super::parse::parse_proc_stat).unwrap_or_default()
290}
291
292/// Read `/proc/loadavg`.
293pub fn read_loadavg(root: &Path) -> Option<[f64; 3]> {
294    let mut path = PathBuf::from(root);
295    path.push("loadavg");
296    with_file(path.as_os_str().as_bytes(), super::parse::parse_loadavg)
297}
298
299/// Read `/proc/uptime`, returning whole seconds.
300pub fn read_uptime(root: &Path) -> Option<u64> {
301    let mut path = PathBuf::from(root);
302    path.push("uptime");
303    with_file(path.as_os_str().as_bytes(), |b| {
304        let s = String::from_utf8_lossy(b);
305        let secs = s.split_whitespace().next()?;
306        secs.split('.').next()?.parse::<u64>().ok()
307    })
308    .flatten()
309}
310
311/// Whether `/proc/<pid>` still refers to the same process identified by
312/// `starttime`. Used to detect PID reuse between two samples.
313pub fn is_same_process(root: &Path, pid: Pid, starttime: u64) -> bool {
314    read_stat(root, pid)
315        .map(|(_, fields)| fields.starttime == starttime)
316        .unwrap_or(false)
317}
318
319/// Read host-wide network counters from `/proc/net/dev`.
320///
321/// Missing on a system with no networking at all; that is reported as zeroes
322/// rather than as an error, because the meter should show `0 B/s`, not vanish.
323pub fn read_net_counters(root: &Path) -> crate::system::NetCounters {
324    let mut path = PathBuf::from(root);
325    path.push("net");
326    path.push("dev");
327    with_file(path.as_os_str().as_bytes(), super::parse::parse_net_dev).unwrap_or_default()
328}
329
330/// Read host-wide disk counters from `/proc/diskstats`.
331///
332/// The authoritative "is this a whole disk" test is the presence of
333/// `/sys/block/<name>`; it is consulted per device, because `/proc/diskstats`
334/// mixes disks and partitions in one file. When `/sys` is not readable (a
335/// fixture tree, a container) the name heuristic is used instead.
336pub fn read_disk_counters(root: &Path) -> crate::system::DiskCounters {
337    // `root` is `/proc`; sysfs lives beside it at `/sys` for the live tree. A
338    // fixture root has neither, and the heuristic covers that case.
339    let sys_block = root
340        .parent()
341        .map(|p| p.join("sys/block"))
342        .filter(|p| p.is_dir());
343
344    let mut path = PathBuf::from(root);
345    path.push("diskstats");
346    with_file(path.as_os_str().as_bytes(), |b| {
347        super::parse::parse_diskstats(b, |name| match &sys_block {
348            Some(dir) => dir
349                .join(std::ffi::OsStr::from_bytes(name))
350                .is_dir(),
351            None => super::parse::looks_like_whole_disk(name),
352        })
353    })
354    .unwrap_or_default()
355}
356
357/// Read `/proc/pressure/{cpu,memory,io,irq}`.
358///
359/// Returns `None` when none of them exist. PSI arrived in Linux 4.20 and is only
360/// present when `CONFIG_PSI=y`; on older kernels or a `psi=0` boot parameter the
361/// files are absent, and a meter that drew four empty bars would look like an
362/// idle machine rather than an unavailable feature.
363///
364/// The four files are read individually: a kernel can expose `cpu` and `memory`
365/// but not `irq` (the per-resource files are gated on the kernel's own feature
366/// detection), so one missing file must not discard the other three.
367pub fn read_psi(root: &Path) -> Option<crate::system::Psi> {
368    let mut psi = crate::system::Psi::default();
369    let read = |name: &str| -> Option<crate::system::PsiResource> {
370        let path = root.join("pressure").join(name);
371        with_file(path.as_os_str().as_bytes(), super::parse::parse_psi)
372    };
373
374    if let Some(r) = read("cpu") {
375        psi.cpu = r;
376    }
377    if let Some(r) = read("memory") {
378        psi.memory = r;
379    }
380    if let Some(r) = read("io") {
381        psi.io = r;
382    }
383    if let Some(r) = read("irq") {
384        psi.irq = r;
385    }
386
387    if psi.any_present() {
388        Some(psi)
389    } else {
390        None
391    }
392}
393
394/// Read every zram device's `/sys/block/zram<N>/mm_stat`.
395///
396/// `root` here is `/proc`, and sysfs lives beside it at `/sys`. A fixture tree or
397/// a container with a masked `/sys` simply reports no devices.
398pub fn read_zram(root: &Path) -> crate::system::ZramInfo {
399    let mut out = crate::system::ZramInfo::default();
400    let Some(sys) = root.parent().map(|p| p.join("sys/block")) else {
401        return out;
402    };
403    let Ok(entries) = fs::read_dir(&sys) else {
404        return out;
405    };
406
407    for entry in entries.flatten() {
408        let name = entry.file_name();
409        let name_bytes = name.as_bytes();
410        // `zram0`, `zram1`, … — a device, not some other entry that happens to
411        // start with the same letters.
412        if !name_bytes.starts_with(b"zram")
413            || !name_bytes[b"zram".len()..].iter().all(u8::is_ascii_digit)
414            || name_bytes.len() == b"zram".len()
415        {
416            continue;
417        }
418        let Ok(name_str) = std::str::from_utf8(name_bytes) else {
419            continue;
420        };
421        let mm_stat = sys.join(&name).join("mm_stat");
422        if let Some(device) = with_file(mm_stat.as_os_str().as_bytes(), |b| {
423            super::parse::parse_zram_mm_stat(b, name_str)
424        })
425        .flatten()
426        {
427            out.devices.push(device);
428        }
429    }
430
431    out.devices.sort_by(|a, b| a.name.cmp(&b.name));
432    out
433}
434
435/// Read `/proc/<pid>/fdinfo/*` and sum every DRM descriptor's counters.
436///
437/// # Cost
438///
439/// One `readdir` plus one open/read per file descriptor. On a desktop this is a
440/// few thousand small reads per sample, which is why it is gated behind
441/// `ScanOptions::gpu` — the same treatment `smaps_rollup` gets. A process with no
442/// descriptors (kernel threads have no `fd` directory at all) costs one failed
443/// `readdir`.
444///
445/// `None` means "no GPU work visible": either the process holds no DRM fd, or the
446/// files are unreadable because it belongs to another user.
447pub fn read_gpu_usage(proc_root: &Path, pid: crate::Pid) -> Option<GpuUsage> {
448    let fdinfo = proc_root.join(pid.to_string()).join("fdinfo");
449    let entries = fs::read_dir(fdinfo).ok()?;
450
451    let mut total: Option<GpuUsage> = None;
452    for entry in entries.flatten() {
453        let path = entry.path();
454        let Ok(bytes) = fs::read(&path) else {
455            continue;
456        };
457        let Some(usage) = super::parse::parse_fdinfo_drm(&bytes) else {
458            continue;
459        };
460        let acc = total.get_or_insert_with(GpuUsage::default);
461        acc.gpu_ns = acc.gpu_ns.saturating_add(usage.gpu_ns);
462        acc.busy_ns = acc.busy_ns.saturating_add(usage.busy_ns);
463        acc.mem_ns = acc.mem_ns.saturating_add(usage.mem_ns);
464    }
465    total
466}
467
468/// Read `max_pool_percent` from `/sys/module/zswap/parameters`.
469///
470/// Used to give the zswap meter a ceiling. Returns `None` when zswap is not
471/// loaded or the parameter is unreadable, in which case the meter scales against
472/// the largest value it has seen instead.
473pub fn read_zswap_max_percent(root: &Path) -> Option<u64> {
474    let sys = root.parent()?.join("sys/module/zswap/parameters");
475    with_file(sys.join("max_pool_percent").as_os_str().as_bytes(), |b| {
476        let text = String::from_utf8_lossy(b);
477        text.trim().parse::<u64>().ok()
478    })
479    .flatten()
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn pid_writing() {
488        for pid in [0u32, 1, 9, 10, 42, 1234, 4_194_304] {
489            let mut v = Vec::new();
490            write_pid(&mut v, pid);
491            assert_eq!(String::from_utf8(v).unwrap(), pid.to_string());
492        }
493    }
494
495    #[test]
496    fn pid_path_shape() {
497        let mut seen = String::new();
498        proc_path(Path::new("/proc"), 42, "stat", |b| {
499            seen = String::from_utf8_lossy(b).into_owned();
500        });
501        assert_eq!(seen, "/proc/42/stat");
502
503        let mut root_only = String::new();
504        proc_path(Path::new("/proc"), 1, "", |b| {
505            root_only = String::from_utf8_lossy(b).into_owned();
506        });
507        assert_eq!(root_only, "/proc/1");
508    }
509
510    #[test]
511    fn argv_joining() {
512        assert_eq!(join_argv(b"/bin/sh\0-c\0echo hi\0"), "/bin/sh -c echo hi");
513        assert_eq!(join_argv(b""), "");
514        assert_eq!(join_argv(b"\0\0"), "");
515    }
516
517    #[test]
518    fn fixture_scan() {
519        let root = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/proc"));
520        if !root.exists() {
521            return; // fixtures are added in a later step
522        }
523        let pids = list_pids(root).expect("readable fixture root");
524        assert!(!pids.is_empty());
525    }
526}