gpuviewer_core/proc_meta.rs
1//! Shared Linux per-process metadata helpers used across every Linux backend.
2//!
3//! GPU drivers tell us *which* PIDs are touching the GPU and how much VRAM they hold; the
4//! rest of a process's identity lives in `/proc`. These helpers turn that into two columns
5//! the story feed leans on:
6//! - **CPU %** ([`CpuTracker`]): a GPU at 3% with python pinning a CPU core is the classic
7//! dataloader-bound fingerprint — the GPU is starved, not idle.
8//! - **container identity** ([`container_of`]): "the run is `docker:1a2b3c4d5e6f`" is what a
9//! cluster operator actually needs to find the offending pod.
10//!
11//! Per the domain rules in CLAUDE.md every value is `Option` and absence is normal: a PID
12//! can exit between the GPU listing it and us reading `/proc`, `/proc/<pid>` may be a foreign
13//! user's process we cannot read, and corrupt/racy reads must yield `None`, never a panic or
14//! a fabricated number. The parsers are split out as pure functions so they unit-test
15//! without a `/proc` at all.
16
17use std::collections::HashMap;
18use std::time::Instant;
19
20/// `USER_HZ` — the kernel reports `/proc/<pid>/stat` CPU times in clock ticks, and this is
21/// the divisor to seconds. It is configurable in theory (`CONFIG_HZ`) but `sysconf(_SC_CLK_TCK)`
22/// returns 100 on every Linux arch/distro gpuviewer supports, and there is no syscall-free
23/// way to read it; hard-coding 100 keeps the core crate dependency-free and is correct on the
24/// targets we ship. (If a wrong-HZ kernel ever surfaces, the clamp below still bounds the lie.)
25const USER_HZ: f64 = 100.0;
26
27/// CPU % is reported relative to one core (100.0 = one full core saturated), so a 64-core box
28/// could legitimately read 6400. Anything past that is a clock-skew / counter-reset artifact,
29/// not a real measurement — clamp to it rather than emit a nonsense spike into the chart.
30const CPU_PCT_MAX: f32 = 6400.0;
31
32/// Parse a container runtime out of `/proc/<pid>/cgroup` content. Pure so it tests against
33/// captured cgroup strings — no `/proc` required.
34///
35/// cgroup v2 puts the whole hierarchy on one `0::<path>` line; v1 has many `id:ctrl:path`
36/// lines. We scan every line's path for a runtime's signature scope/dir name, because the
37/// signature can sit at any depth (systemd slices nest it under `system.slice`, kubelet under
38/// `kubepods.slice/kubepods-besteffort.slice/...`). The first runtime we recognize wins.
39///
40/// Returns a short, stable label (`docker:<12 hex>`, `k8s:<12>`, `podman:<12>`, `lxc:<name>`)
41/// or `None` for a host process. We deliberately do NOT invent an id when none is parseable
42/// (bare `kubepods` with no pod id → `k8s:?`): a truncated id presented as exact would be a
43/// quiet lie, and matching the wrong pod is worse than admitting we only know "some pod".
44pub fn parse_cgroup(content: &str) -> Option<String> {
45 for line in content.lines() {
46 // v1 lines are `hierarchy-id:controllers:path`; v2 is `0::path`. In both, the path is
47 // everything after the last colon — splitting on ':' and taking the remainder is safe
48 // because a cgroup path cannot itself contain a colon.
49 let path = line.rsplit(':').next().unwrap_or(line);
50
51 for seg in path.split('/') {
52 if let Some(label) = recognize_segment(seg) {
53 return Some(label);
54 }
55 }
56
57 // `lxc/<name>` (cgroupfs driver) and `lxc.payload.<name>` (newer LXC) both name the
58 // container in a segment rather than an opaque id; surface the human name as-is.
59 if let Some(name) = lxc_name(path) {
60 return Some(format!("lxc:{name}"));
61 }
62
63 // kubepods anywhere in the path means k8s even when no recognizable container id
64 // scope is present (e.g. the pod-level slice). Last resort so a more specific
65 // crio/containerd id above is preferred.
66 if path.split('/').any(|s| s.starts_with("kubepods")) {
67 return Some("k8s:?".into());
68 }
69 }
70 None
71}
72
73/// Match a single path segment against the known systemd-scope shapes. The id inside a scope
74/// is a 64-hex container id (or a `cri-containerd-<id>` / `crio-<id>` variant); we keep the
75/// first 12 hex — the same short form `docker ps` shows — so labels are comparable by eye.
76fn recognize_segment(seg: &str) -> Option<String> {
77 // Strip the `.scope` / `.service` systemd suffix once; the runtime prefix is what matters.
78 let body = seg.strip_suffix(".scope").unwrap_or(seg);
79
80 // Order matters: `cri-containerd-` must be tested before a bare `containerd` check, and
81 // both before the generic forms, so the most specific runtime label wins.
82 if let Some(id) = body.strip_prefix("docker-") {
83 return short_hex(id).map(|h| format!("docker:{h}"));
84 }
85 if let Some(id) = body.strip_prefix("cri-containerd-") {
86 return short_hex(id).map(|h| format!("k8s:{h}"));
87 }
88 if let Some(id) = body.strip_prefix("crio-") {
89 return short_hex(id).map(|h| format!("k8s:{h}"));
90 }
91 if let Some(id) = body.strip_prefix("containerd-") {
92 return short_hex(id).map(|h| format!("k8s:{h}"));
93 }
94 if let Some(id) = body.strip_prefix("libpod-") {
95 return short_hex(id).map(|h| format!("podman:{h}"));
96 }
97 None
98}
99
100/// The container name for the two LXC path layouts, or `None`. We only treat a segment as the
101/// name when its predecessor is the `lxc` / `lxc.payload` marker, so an unrelated dir literally
102/// called `lxc-foo` elsewhere in the path does not get mistaken for a container.
103fn lxc_name(path: &str) -> Option<&str> {
104 let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
105 for (i, seg) in segs.iter().enumerate() {
106 // `lxc/<name>`: the marker is its own segment.
107 if *seg == "lxc" {
108 if let Some(name) = segs.get(i + 1) {
109 return non_empty(name);
110 }
111 }
112 // `lxc.payload.<name>`: the name is glued onto the marker in one segment.
113 if let Some(name) = seg.strip_prefix("lxc.payload.") {
114 return non_empty(name);
115 }
116 }
117 None
118}
119
120/// First 12 chars of a string that is entirely lowercase/uppercase hex of length ≥ 12.
121/// Rejecting non-hex and too-short ids is what keeps `docker-<not an id>.scope` from
122/// producing a confident-looking but fake `docker:` label.
123fn short_hex(id: &str) -> Option<String> {
124 if id.len() >= 12 && id.bytes().all(|b| b.is_ascii_hexdigit()) {
125 Some(id[..12].to_ascii_lowercase())
126 } else {
127 None
128 }
129}
130
131fn non_empty(s: &str) -> Option<&str> {
132 (!s.is_empty()).then_some(s)
133}
134
135/// Container identity for a live PID by reading `/proc/<pid>/cgroup`. Any IO error (PID gone,
136/// foreign user, no procfs) reads as "host / unknown" → `None`; this never fails.
137pub fn container_of(pid: u32) -> Option<String> {
138 let content = std::fs::read_to_string(format!("/proc/{pid}/cgroup")).ok()?;
139 parse_cgroup(&content)
140}
141
142/// Tracks per-PID CPU time across ticks to turn the kernel's *cumulative* counter into an
143/// instantaneous rate. A single `/proc/<pid>/stat` read only gives total ticks consumed since
144/// the process started; the rate is the delta between two reads over the wall time between them.
145#[derive(Default)]
146pub struct CpuTracker {
147 /// pid → (cumulative utime+stime ticks, the instant we read them).
148 seen: HashMap<u32, (u64, Instant)>,
149}
150
151impl CpuTracker {
152 pub fn new() -> Self {
153 Self::default()
154 }
155
156 /// CPU % (relative to one core) for `pid`, or `None`. The first sighting only establishes
157 /// a baseline — there is no prior point to difference against — so it returns `None` by
158 /// design rather than fabricating a since-boot average. Later sightings return the rate.
159 /// A missing/unreadable stat (PID exited, foreign user) is a normal `None`.
160 pub fn sample(&mut self, pid: u32) -> Option<f32> {
161 let content = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
162 let ticks = parse_stat_ticks(&content)?;
163 let now = Instant::now();
164
165 let prev = self.seen.insert(pid, (ticks, now));
166 let (prev_ticks, prev_instant) = prev?; // first sighting → baseline only, no rate yet
167
168 // A counter that went backwards means pid reuse re-created the process between reads;
169 // checked_sub yields None and we make no claim rather than report a wild value.
170 let delta_ticks = ticks.checked_sub(prev_ticks)?;
171 let elapsed = now.duration_since(prev_instant).as_secs_f64();
172 if elapsed <= 0.0 {
173 return None;
174 }
175
176 let pct = (delta_ticks as f64 / USER_HZ / elapsed * 100.0) as f32;
177 Some(pct.clamp(0.0, CPU_PCT_MAX))
178 }
179
180 /// Drop bookkeeping for PIDs no longer present so the map does not grow without bound over
181 /// a long-running session (a busy host churns through thousands of short-lived PIDs).
182 pub fn prune(&mut self, live_pids: &[u32]) {
183 self.seen.retain(|pid, _| live_pids.contains(pid));
184 }
185}
186
187/// Sum of `utime` + `stime` (fields 14 and 15, 1-indexed) from a `/proc/<pid>/stat` line.
188///
189/// The classic parsing trap: field 2 is `comm`, the executable name in parentheses, and it can
190/// contain spaces AND parentheses — e.g. a thread named `(my proc) worker` yields
191/// `1234 ((my proc) worker) S ...`. Splitting the whole line on whitespace therefore mis-counts
192/// fields. The robust fix the kernel itself documents: `comm` is wrapped in the FIRST `(` and
193/// the LAST `)`, so slice from after the last `)` and field-count the tail. After that `)` the
194/// fields are fixed-position and space-separated, with `state` first — so utime/stime are the
195/// 12th and 13th tokens of the tail (stat fields 14/15 = tail positions 12/13, since the tail
196/// begins at field 3 `state`).
197pub fn parse_stat_ticks(stat: &str) -> Option<u64> {
198 // Everything after the last ')' is the parentheses-free tail starting at the `state` field.
199 let tail = stat.rsplit_once(')')?.1;
200 let mut fields = tail.split_whitespace();
201
202 // Tail field 1 is `state`; stat field 14 (utime) is tail field 12, field 15 (stime) is 13.
203 let utime: u64 = fields.nth(11)?.parse().ok()?;
204 let stime: u64 = fields.next()?.parse().ok()?;
205 utime.checked_add(stime)
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 // ---- cgroup parsing: realistic cgroup v2 content per runtime ----
213
214 #[test]
215 fn docker_scope_yields_short_id() {
216 // systemd cgroup driver, cgroup v2: one `0::` line, docker scope under system.slice.
217 let c = "0::/system.slice/docker-1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d.scope\n";
218 assert_eq!(parse_cgroup(c).as_deref(), Some("docker:1a2b3c4d5e6f"));
219 }
220
221 #[test]
222 fn cri_containerd_and_crio_map_to_k8s() {
223 let containerd = "0::/kubepods.slice/kubepods-burstable.slice/\
224 kubepods-burstable-pod123.slice/\
225 cri-containerd-abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789.scope\n";
226 assert_eq!(
227 parse_cgroup(containerd).as_deref(),
228 Some("k8s:abcdef012345")
229 );
230
231 let crio = "0::/kubepods.slice/kubepods-besteffort.slice/\
232 crio-fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210.scope\n";
233 assert_eq!(parse_cgroup(crio).as_deref(), Some("k8s:fedcba987654"));
234 }
235
236 #[test]
237 fn kubepods_without_recognizable_id_is_unknown_pod() {
238 // The pod-level slice with no container scope: we know it is k8s but not which pod.
239 let c = "0::/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-pod9f8e.slice\n";
240 assert_eq!(parse_cgroup(c).as_deref(), Some("k8s:?"));
241 }
242
243 #[test]
244 fn libpod_scope_yields_podman() {
245 let c = "0::/machine.slice/\
246 libpod-0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0.scope\n";
247 assert_eq!(parse_cgroup(c).as_deref(), Some("podman:0f1e2d3c4b5a"));
248 }
249
250 #[test]
251 fn lxc_both_path_layouts_surface_the_name() {
252 // cgroupfs driver: `lxc/<name>`.
253 let plain = "11:cpuset:/lxc/webserver\n10:devices:/lxc/webserver\n";
254 assert_eq!(parse_cgroup(plain).as_deref(), Some("lxc:webserver"));
255 // newer LXC: `lxc.payload.<name>`.
256 let payload = "0::/lxc.payload.dbnode/system.slice/postgres.service\n";
257 assert_eq!(parse_cgroup(payload).as_deref(), Some("lxc:dbnode"));
258 }
259
260 #[test]
261 fn host_process_is_none() {
262 // A plain systemd user-session process: no container marker anywhere.
263 let c = "0::/user.slice/user-1000.slice/session-2.scope\n";
264 assert_eq!(parse_cgroup(c), None);
265 // The init/system path is likewise host.
266 assert_eq!(parse_cgroup("0::/system.slice/sshd.service\n"), None);
267 }
268
269 #[test]
270 fn garbage_and_decoy_ids_are_none() {
271 assert_eq!(parse_cgroup(""), None);
272 assert_eq!(parse_cgroup("not a cgroup file at all"), None);
273 // `docker-` prefix but the id is not hex / too short → not a real container id.
274 assert_eq!(
275 parse_cgroup("0::/system.slice/docker-notanid.scope\n"),
276 None
277 );
278 assert_eq!(
279 parse_cgroup("0::/system.slice/docker-deadbeef.scope\n"),
280 None
281 );
282 }
283
284 // ---- /proc/<pid>/stat parsing: the comm-with-parens trap ----
285
286 #[test]
287 fn stat_ticks_handles_comm_with_spaces_and_parens() {
288 // comm = `(my proc) worker` — contains ") (" and a leading paren. utime=111, stime=22.
289 // Layout: pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt
290 // majflt cmajflt utime stime ...
291 let line = "1234 ((my proc) worker) S 1 1234 1234 0 -1 4194560 \
292 1000 0 5 0 111 22 0 0 20 0 1 0 9999 123456 789 ...\n";
293 assert_eq!(parse_stat_ticks(line), Some(133));
294 }
295
296 #[test]
297 fn stat_ticks_simple_comm() {
298 // Plain comm with no parens: utime=50, stime=10 → 60.
299 let line = "980 (python) R 1 980 980 0 -1 4194304 200 0 0 0 50 10 0 0 20 0 8 0 1000 0 0\n";
300 assert_eq!(parse_stat_ticks(line), Some(60));
301 }
302
303 #[test]
304 fn stat_ticks_rejects_truncated_or_garbage() {
305 // No closing paren at all.
306 assert_eq!(parse_stat_ticks("1234 (python S 1 1 1"), None);
307 // Tail too short to reach utime/stime.
308 assert_eq!(parse_stat_ticks("1234 (python) S 1 1"), None);
309 // utime is non-numeric.
310 let bad = "1 (x) S 1 1 1 0 -1 0 0 0 0 0 xx 22 0 0 20 0 1 0 1 0 0\n";
311 assert_eq!(parse_stat_ticks(bad), None);
312 }
313
314 // ---- CpuTracker: baseline-then-rate semantics, pruning ----
315
316 #[test]
317 fn cpu_tracker_first_sighting_has_no_baseline() {
318 // We cannot read a synthetic /proc here, but the pruning/baseline bookkeeping is
319 // exercisable: a never-seen pid sampled from a real /proc still returns None the
320 // first time. Sampling our own pid twice would race the scheduler, so we only assert
321 // the no-baseline contract on the first read.
322 let mut t = CpuTracker::new();
323 let me = std::process::id();
324 assert_eq!(
325 t.sample(me),
326 None,
327 "first sighting establishes baseline only"
328 );
329 }
330
331 #[test]
332 fn cpu_tracker_prune_drops_dead_pids() {
333 let mut t = CpuTracker::new();
334 // Seed the map directly to test prune without depending on /proc timing.
335 t.seen.insert(111, (10, Instant::now()));
336 t.seen.insert(222, (20, Instant::now()));
337 t.seen.insert(333, (30, Instant::now()));
338 t.prune(&[222]);
339 assert!(!t.seen.contains_key(&111));
340 assert!(t.seen.contains_key(&222));
341 assert!(!t.seen.contains_key(&333));
342 }
343}