Skip to main content

znippy_common/
hostinfo.rs

1//! Host memory and CPU facts, read straight from `/proc` and `/sys/fs/cgroup`.
2//!
3//! # Why this module exists
4//!
5//! `znippy-common` sits under every other crate in the workspace and under every
6//! external consumer, so anything it links is paid by all of them, on every
7//! target including `wasm32`. It used to get these five numbers from `sysinfo`,
8//! and that cost more than it looked:
9//!
10//!   * `sysinfo`'s `multithread` feature is defined upstream as
11//!     `multithread = ["dep:rayon"]`, so for as long as this crate asked for it,
12//!     linking `znippy-common` put `rayon` + `rayon-core` into every consumer's
13//!     graph — ROOT LAW #0 broken by a third party naming rayon on our behalf.
14//!   * Even with that feature off, `sysinfo` is a general process/disk/network/
15//!     component/user prober behind five numbers we read once at startup.
16//!
17//! The whole surface actually used was `total_memory`, `available_memory`,
18//! `cgroup_limits().{total_memory, rss}`, `physical_core_count` and
19//! `cpus().len()`. Every one of them is a short read of a text file the kernel
20//! already exports, which is how the rest of this fleet gets them. `sysinfo` is
21//! now a **dev-dependency only**, where it earns its place as the independent
22//! oracle this module is checked against — see
23//! `znippy-common/tests/hostinfo_vs_sysinfo.rs`.
24//!
25//! # The behaviour this FIXES
26//!
27//! `sysinfo::System::cgroup_limits()` is `limits_for_system()`, which reads the
28//! **cgroup-v2 root** (`/sys/fs/cgroup/memory.max`, `/sys/fs/cgroup/memory.current`).
29//! Neither file exists at the root on a standard cgroup-v2 host — the root cgroup
30//! has no memory controller entries of its own — so that call returns `None`, and
31//! it returns `None` *whether or not the process is actually inside a limited
32//! cgroup*. Measured on oden, 2026-08-04: root has no `memory.max`, while this
33//! process's own cgroup (`/system.slice/claude-tmux.service`) reports
34//! `memory.max = 472446402560`.
35//!
36//! So the "in a container the cgroup ceiling, not the host's free RAM, is what the
37//! OOM killer measures against" tier in [`crate::common_config`] could never fire.
38//! It was documented, tested green, and dead. [`cgroup_memory`] reads the
39//! **process's own** cgroup via `/proc/self/cgroup`, so that tier now works.
40
41/// Memory facts for the cgroup this process is actually in.
42///
43/// Field meanings match what [`crate::common_config::slot_pool_budget_bytes`]
44/// consumes, and match `sysinfo::CGroupLimits` for the two fields we use.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct CgroupMemory {
47    /// The effective ceiling: the smallest `memory.max` on the path from this
48    /// process's cgroup up to the root, clamped to host `MemTotal`.
49    pub total_memory: u64,
50    /// Anonymous (non-reclaimable) memory charged to the cgroup — `anon` on v2,
51    /// `total_rss` on v1. This is the part the OOM killer cannot get back.
52    pub rss: u64,
53}
54
55// ─────────────────────────────────────────────────────────────────────────────
56// /proc/meminfo
57// ─────────────────────────────────────────────────────────────────────────────
58
59/// One pass over `/proc/meminfo`, returning the keys asked for, in bytes.
60///
61/// `/proc/meminfo` says `kB` but reports KiB; multiply by 1024, exactly as
62/// `sysinfo` does. Missing keys come back `None` rather than `0`, so a caller can
63/// tell "the kernel did not report this" from "the kernel reported zero".
64#[cfg(target_os = "linux")]
65fn meminfo_bytes(keys: &[&str]) -> Vec<Option<u64>> {
66    let mut out = vec![None; keys.len()];
67    let Ok(content) = std::fs::read_to_string("/proc/meminfo") else {
68        return out;
69    };
70    for line in content.lines() {
71        let Some((key, rest)) = line.split_once(':') else {
72            continue;
73        };
74        let key = key.trim();
75        let Some(i) = keys.iter().position(|k| *k == key) else {
76            continue;
77        };
78        out[i] = rest
79            .split_whitespace()
80            .next()
81            .and_then(|v| v.parse::<u64>().ok())
82            .map(|kib| kib.saturating_mul(1024));
83    }
84    out
85}
86
87/// Total physical RAM in bytes (`MemTotal`). `None` if unreadable.
88pub fn mem_total_bytes() -> Option<u64> {
89    #[cfg(target_os = "linux")]
90    {
91        meminfo_bytes(&["MemTotal"])[0]
92    }
93    #[cfg(not(target_os = "linux"))]
94    {
95        None
96    }
97}
98
99/// Memory that can be handed out without swapping (`MemAvailable`), in bytes.
100///
101/// `MemFree` is the wrong number on a build box — it ignores reclaimable page
102/// cache and under-counts badly. On kernels before 3.14 `MemAvailable` does not
103/// exist, and this falls back to the same estimate `sysinfo` uses:
104/// `free + buffers + cached + sreclaimable - shmem`.
105pub fn mem_available_bytes() -> Option<u64> {
106    #[cfg(target_os = "linux")]
107    {
108        let v = meminfo_bytes(&[
109            "MemAvailable",
110            "MemFree",
111            "Buffers",
112            "Cached",
113            "SReclaimable",
114            "Shmem",
115        ]);
116        if let Some(avail) = v[0] {
117            return Some(avail);
118        }
119        let free = v[1]?;
120        Some(
121            free.saturating_add(v[2].unwrap_or(0))
122                .saturating_add(v[3].unwrap_or(0))
123                .saturating_add(v[4].unwrap_or(0))
124                .saturating_sub(v[5].unwrap_or(0)),
125        )
126    }
127    #[cfg(not(target_os = "linux"))]
128    {
129        None
130    }
131}
132
133// ─────────────────────────────────────────────────────────────────────────────
134// cgroup (v2 preferred, v1 fallback) — the PROCESS's own cgroup
135// ─────────────────────────────────────────────────────────────────────────────
136
137#[cfg(target_os = "linux")]
138fn read_u64_file(path: &std::path::Path) -> Option<u64> {
139    std::fs::read_to_string(path).ok()?.trim().parse().ok()
140}
141
142/// `memory.max` reads as the literal `max` when unlimited; treat that, and any
143/// unreadable file, as no ceiling.
144#[cfg(target_os = "linux")]
145fn read_v2_max(path: &std::path::Path) -> u64 {
146    match std::fs::read_to_string(path) {
147        Ok(s) if s.trim() != "max" => s.trim().parse().unwrap_or(u64::MAX),
148        _ => u64::MAX,
149    }
150}
151
152/// Look up one key in a `memory.stat`-style `"<key> <value>\n"` table.
153#[cfg(target_os = "linux")]
154fn read_stat_key(path: &std::path::Path, want: &str) -> Option<u64> {
155    let content = std::fs::read_to_string(path).ok()?;
156    for line in content.lines() {
157        let mut it = line.split_whitespace();
158        if it.next() == Some(want) {
159            return it.next().and_then(|v| v.parse().ok());
160        }
161    }
162    None
163}
164
165/// This process's cgroup path, relative to the hierarchy root.
166///
167/// `/proc/self/cgroup` is `0::/some/path` on v2 (one line, empty controller
168/// field). On v1 each line is `<id>:<controllers>:<path>` and we want the entry
169/// that carries the `memory` controller.
170#[cfg(target_os = "linux")]
171fn self_cgroup_path(v2: bool) -> Option<String> {
172    let content = std::fs::read_to_string("/proc/self/cgroup").ok()?;
173    for line in content.lines() {
174        let mut parts = line.splitn(3, ':');
175        let _id = parts.next()?;
176        let controllers = parts.next()?;
177        let path = parts.next()?;
178        if v2 {
179            if controllers.is_empty() {
180                return Some(path.trim_start_matches('/').to_string());
181            }
182        } else if controllers.split(',').any(|c| c == "memory") {
183            return Some(path.trim_start_matches('/').to_string());
184        }
185    }
186    None
187}
188
189/// The effective ceiling and free headroom for `base`, taking the tightest limit
190/// on the walk from `base` up to `root`.
191///
192/// A cgroup does not have to be the one that binds: a parent slice can carry a
193/// smaller `memory.max` than the leaf. Walking the ancestry and taking the
194/// minimum is what makes the answer the one the OOM killer will actually use.
195#[cfg(target_os = "linux")]
196fn tightest_limit(
197    base: &std::path::Path,
198    root: &std::path::Path,
199    limit_file: &str,
200    usage_file: &str,
201    mem_total: u64,
202    read_limit: fn(&std::path::Path) -> u64,
203) -> Option<u64> {
204    // The leaf must at least be chargeable; if its usage file is missing we are
205    // not looking at a live memory-controller cgroup at all.
206    read_u64_file(&base.join(usage_file))?;
207    let mut total = mem_total;
208    for path in base.ancestors() {
209        let max = read_limit(&path.join(limit_file));
210        if max <= mem_total {
211            total = total.min(max);
212        }
213        if path == root {
214            return Some(total);
215        }
216    }
217    // Walked past the hierarchy root without meeting it — treat as unknown
218    // rather than inventing a number.
219    None
220}
221
222/// Memory ceiling and RSS for the cgroup **this process** is in, or `None` when
223/// the process is not under a memory-controlled cgroup.
224///
225/// Unlike `sysinfo::System::cgroup_limits`, which inspects the hierarchy *root*
226/// and therefore answers `None` on a normal cgroup-v2 host regardless of the
227/// process's own limits, this follows `/proc/self/cgroup`. See the module docs.
228pub fn cgroup_memory() -> Option<CgroupMemory> {
229    #[cfg(target_os = "linux")]
230    {
231        use std::path::Path;
232        let mem_total = mem_total_bytes()?;
233
234        // cgroup v2
235        let v2_root = Path::new("/sys/fs/cgroup");
236        if let Some(rel) = self_cgroup_path(true) {
237            let base = v2_root.join(&rel);
238            if let (Some(total_memory), Some(rss)) = (
239                tightest_limit(
240                    &base,
241                    v2_root,
242                    "memory.max",
243                    "memory.current",
244                    mem_total,
245                    read_v2_max,
246                ),
247                read_stat_key(&base.join("memory.stat"), "anon"),
248            ) {
249                return Some(CgroupMemory { total_memory, rss });
250            }
251        }
252
253        // cgroup v1
254        let v1_root = Path::new("/sys/fs/cgroup/memory");
255        let rel = self_cgroup_path(false)?;
256        let base = v1_root.join(&rel);
257        let total_memory = tightest_limit(
258            &base,
259            v1_root,
260            "memory.limit_in_bytes",
261            "memory.usage_in_bytes",
262            mem_total,
263            |p| read_u64_file(p).unwrap_or(u64::MAX),
264        )?;
265        let rss = read_stat_key(&base.join("memory.stat"), "total_rss")?;
266        Some(CgroupMemory { total_memory, rss })
267    }
268    #[cfg(not(target_os = "linux"))]
269    {
270        None
271    }
272}
273
274// ─────────────────────────────────────────────────────────────────────────────
275// /proc/cpuinfo
276// ─────────────────────────────────────────────────────────────────────────────
277
278/// Distinct physical cores, counting an SMT sibling pair once.
279///
280/// `/proc/cpuinfo` is blank-line-delimited blocks, one per logical CPU. A core is
281/// identified by the `(physical id, core id)` pair. Some machines (Raspberry Pi
282/// and most non-x86) print neither, and there the `processor` number is the best
283/// available identity — one entry, one core. This is a direct port of `sysinfo`'s
284/// algorithm, so the dev-dep oracle test can assert exact equality.
285pub fn physical_core_count() -> Option<usize> {
286    #[cfg(target_os = "linux")]
287    {
288        use std::collections::HashSet;
289        let content = std::fs::read_to_string("/proc/cpuinfo").ok()?;
290        let mut seen: HashSet<String> = HashSet::new();
291        let (mut core_id, mut physical_id, mut cpu) = (String::new(), String::new(), String::new());
292
293        let mut flush = |core_id: &mut String, physical_id: &mut String, cpu: &mut String| {
294            if !core_id.is_empty() && !physical_id.is_empty() {
295                seen.insert(format!("{core_id} {physical_id}"));
296            } else if !cpu.is_empty() {
297                seen.insert(cpu.clone());
298            }
299            core_id.clear();
300            physical_id.clear();
301            cpu.clear();
302        };
303
304        // Mirrors sysinfo's `line.splitn(2, ':').last().trim()`: everything after
305        // the first colon, or the whole line when there is none. The degenerate
306        // no-colon case is kept identical so the oracle test can assert exact
307        // equality rather than "close enough".
308        fn after_colon(line: &str) -> &str {
309            match line.split_once(':') {
310                Some((_, rest)) => rest.trim(),
311                None => line.trim(),
312            }
313        }
314
315        for line in content.lines() {
316            if line.is_empty() {
317                flush(&mut core_id, &mut physical_id, &mut cpu);
318            } else if line.starts_with("processor") {
319                cpu = after_colon(line).to_string();
320            } else if line.starts_with("core id") {
321                core_id = after_colon(line).to_string();
322            } else if line.starts_with("physical id") {
323                physical_id = after_colon(line).to_string();
324            }
325        }
326        flush(&mut core_id, &mut physical_id, &mut cpu);
327
328        if seen.is_empty() { None } else { Some(seen.len()) }
329    }
330    #[cfg(not(target_os = "linux"))]
331    {
332        None
333    }
334}
335
336/// Logical CPUs the kernel exposes — `processor` lines in `/proc/cpuinfo`, which
337/// is what `sysinfo::System::cpus().len()` counts.
338///
339/// Falls back to [`std::thread::available_parallelism`] (and finally 1) so this
340/// never returns 0; a 0 here would divide the whole pipeline into nothing.
341pub fn logical_cpu_count() -> usize {
342    #[cfg(target_os = "linux")]
343    if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
344        let n = content
345            .lines()
346            .filter(|l| l.starts_with("processor") && l.contains(':'))
347            .count();
348        if n > 0 {
349            return n;
350        }
351    }
352    std::thread::available_parallelism()
353        .map(|n| n.get())
354        .unwrap_or(1)
355}