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}