znippy-common/src/hostinfo.rs
//! Host memory and CPU facts, read straight from `/proc` and `/sys/fs/cgroup`.
//!
//! # Why this module exists
//!
//! `znippy-common` sits under every other crate in the workspace and under every
//! external consumer, so anything it links is paid by all of them, on every
//! target including `wasm32`. It used to get these five numbers from `sysinfo`,
//! and that cost more than it looked:
//!
//! * `sysinfo`'s `multithread` feature is defined upstream as
//! `multithread = ["dep:rayon"]`, so for as long as this crate asked for it,
//! linking `znippy-common` put `rayon` + `rayon-core` into every consumer's
//! graph — ROOT LAW #0 broken by a third party naming rayon on our behalf.
//! * Even with that feature off, `sysinfo` is a general process/disk/network/
//! component/user prober behind five numbers we read once at startup.
//!
//! The whole surface actually used was `total_memory`, `available_memory`,
//! `cgroup_limits().{total_memory, rss}`, `physical_core_count` and
//! `cpus().len()`. Every one of them is a short read of a text file the kernel
//! already exports, which is how the rest of this fleet gets them. `sysinfo` is
//! now a **dev-dependency only**, where it earns its place as the independent
//! oracle this module is checked against — see
//! `znippy-common/tests/hostinfo_vs_sysinfo.rs`.
//!
//! # The behaviour this FIXES
//!
//! `sysinfo::System::cgroup_limits()` is `limits_for_system()`, which reads the
//! **cgroup-v2 root** (`/sys/fs/cgroup/memory.max`, `/sys/fs/cgroup/memory.current`).
//! Neither file exists at the root on a standard cgroup-v2 host — the root cgroup
//! has no memory controller entries of its own — so that call returns `None`, and
//! it returns `None` *whether or not the process is actually inside a limited
//! cgroup*. Measured on oden, 2026-08-04: root has no `memory.max`, while this
//! process's own cgroup (`/system.slice/claude-tmux.service`) reports
//! `memory.max = 472446402560`.
//!
//! So the "in a container the cgroup ceiling, not the host's free RAM, is what the
//! OOM killer measures against" tier in [`crate::common_config`] could never fire.
//! It was documented, tested green, and dead. [`cgroup_memory`] reads the
//! **process's own** cgroup via `/proc/self/cgroup`, so that tier now works.
/// Memory facts for the cgroup this process is actually in.
///
/// Field meanings match what [`crate::common_config::slot_pool_budget_bytes`]
/// consumes, and match `sysinfo::CGroupLimits` for the two fields we use.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CgroupMemory {
/// The effective ceiling: the smallest `memory.max` on the path from this
/// process's cgroup up to the root, clamped to host `MemTotal`.
pub total_memory: u64,
/// Anonymous (non-reclaimable) memory charged to the cgroup — `anon` on v2,
/// `total_rss` on v1. This is the part the OOM killer cannot get back.
pub rss: u64,
}
// ─────────────────────────────────────────────────────────────────────────────
// /proc/meminfo
// ─────────────────────────────────────────────────────────────────────────────
/// One pass over `/proc/meminfo`, returning the keys asked for, in bytes.
///
/// `/proc/meminfo` says `kB` but reports KiB; multiply by 1024, exactly as
/// `sysinfo` does. Missing keys come back `None` rather than `0`, so a caller can
/// tell "the kernel did not report this" from "the kernel reported zero".
#[cfg(target_os = "linux")]
fn meminfo_bytes(keys: &[&str]) -> Vec<Option<u64>> {
let mut out = vec![None; keys.len()];
let Ok(content) = std::fs::read_to_string("/proc/meminfo") else {
return out;
};
for line in content.lines() {
let Some((key, rest)) = line.split_once(':') else {
continue;
};
let key = key.trim();
let Some(i) = keys.iter().position(|k| *k == key) else {
continue;
};
out[i] = rest
.split_whitespace()
.next()
.and_then(|v| v.parse::<u64>().ok())
.map(|kib| kib.saturating_mul(1024));
}
out
}
/// Total physical RAM in bytes (`MemTotal`). `None` if unreadable.
pub fn mem_total_bytes() -> Option<u64> {
#[cfg(target_os = "linux")]
{
meminfo_bytes(&["MemTotal"])[0]
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
/// Memory that can be handed out without swapping (`MemAvailable`), in bytes.
///
/// `MemFree` is the wrong number on a build box — it ignores reclaimable page
/// cache and under-counts badly. On kernels before 3.14 `MemAvailable` does not
/// exist, and this falls back to the same estimate `sysinfo` uses:
/// `free + buffers + cached + sreclaimable - shmem`.
pub fn mem_available_bytes() -> Option<u64> {
#[cfg(target_os = "linux")]
{
let v = meminfo_bytes(&[
"MemAvailable",
"MemFree",
"Buffers",
"Cached",
"SReclaimable",
"Shmem",
]);
if let Some(avail) = v[0] {
return Some(avail);
}
let free = v[1]?;
Some(
free.saturating_add(v[2].unwrap_or(0))
.saturating_add(v[3].unwrap_or(0))
.saturating_add(v[4].unwrap_or(0))
.saturating_sub(v[5].unwrap_or(0)),
)
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
// ─────────────────────────────────────────────────────────────────────────────
// cgroup (v2 preferred, v1 fallback) — the PROCESS's own cgroup
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(target_os = "linux")]
fn read_u64_file(path: &std::path::Path) -> Option<u64> {
std::fs::read_to_string(path).ok()?.trim().parse().ok()
}
/// `memory.max` reads as the literal `max` when unlimited; treat that, and any
/// unreadable file, as no ceiling.
#[cfg(target_os = "linux")]
fn read_v2_max(path: &std::path::Path) -> u64 {
match std::fs::read_to_string(path) {
Ok(s) if s.trim() != "max" => s.trim().parse().unwrap_or(u64::MAX),
_ => u64::MAX,
}
}
/// Look up one key in a `memory.stat`-style `"<key> <value>\n"` table.
#[cfg(target_os = "linux")]
fn read_stat_key(path: &std::path::Path, want: &str) -> Option<u64> {
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let mut it = line.split_whitespace();
if it.next() == Some(want) {
return it.next().and_then(|v| v.parse().ok());
}
}
None
}
/// This process's cgroup path, relative to the hierarchy root.
///
/// `/proc/self/cgroup` is `0::/some/path` on v2 (one line, empty controller
/// field). On v1 each line is `<id>:<controllers>:<path>` and we want the entry
/// that carries the `memory` controller.
#[cfg(target_os = "linux")]
fn self_cgroup_path(v2: bool) -> Option<String> {
let content = std::fs::read_to_string("/proc/self/cgroup").ok()?;
for line in content.lines() {
let mut parts = line.splitn(3, ':');
let _id = parts.next()?;
let controllers = parts.next()?;
let path = parts.next()?;
if v2 {
if controllers.is_empty() {
return Some(path.trim_start_matches('/').to_string());
}
} else if controllers.split(',').any(|c| c == "memory") {
return Some(path.trim_start_matches('/').to_string());
}
}
None
}
/// The effective ceiling and free headroom for `base`, taking the tightest limit
/// on the walk from `base` up to `root`.
///
/// A cgroup does not have to be the one that binds: a parent slice can carry a
/// smaller `memory.max` than the leaf. Walking the ancestry and taking the
/// minimum is what makes the answer the one the OOM killer will actually use.
#[cfg(target_os = "linux")]
fn tightest_limit(
base: &std::path::Path,
root: &std::path::Path,
limit_file: &str,
usage_file: &str,
mem_total: u64,
read_limit: fn(&std::path::Path) -> u64,
) -> Option<u64> {
// The leaf must at least be chargeable; if its usage file is missing we are
// not looking at a live memory-controller cgroup at all.
read_u64_file(&base.join(usage_file))?;
let mut total = mem_total;
for path in base.ancestors() {
let max = read_limit(&path.join(limit_file));
if max <= mem_total {
total = total.min(max);
}
if path == root {
return Some(total);
}
}
// Walked past the hierarchy root without meeting it — treat as unknown
// rather than inventing a number.
None
}
/// Memory ceiling and RSS for the cgroup **this process** is in, or `None` when
/// the process is not under a memory-controlled cgroup.
///
/// Unlike `sysinfo::System::cgroup_limits`, which inspects the hierarchy *root*
/// and therefore answers `None` on a normal cgroup-v2 host regardless of the
/// process's own limits, this follows `/proc/self/cgroup`. See the module docs.
pub fn cgroup_memory() -> Option<CgroupMemory> {
#[cfg(target_os = "linux")]
{
use std::path::Path;
let mem_total = mem_total_bytes()?;
// cgroup v2
let v2_root = Path::new("/sys/fs/cgroup");
if let Some(rel) = self_cgroup_path(true) {
let base = v2_root.join(&rel);
if let (Some(total_memory), Some(rss)) = (
tightest_limit(
&base,
v2_root,
"memory.max",
"memory.current",
mem_total,
read_v2_max,
),
read_stat_key(&base.join("memory.stat"), "anon"),
) {
return Some(CgroupMemory { total_memory, rss });
}
}
// cgroup v1
let v1_root = Path::new("/sys/fs/cgroup/memory");
let rel = self_cgroup_path(false)?;
let base = v1_root.join(&rel);
let total_memory = tightest_limit(
&base,
v1_root,
"memory.limit_in_bytes",
"memory.usage_in_bytes",
mem_total,
|p| read_u64_file(p).unwrap_or(u64::MAX),
)?;
let rss = read_stat_key(&base.join("memory.stat"), "total_rss")?;
Some(CgroupMemory { total_memory, rss })
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
// ─────────────────────────────────────────────────────────────────────────────
// /proc/cpuinfo
// ─────────────────────────────────────────────────────────────────────────────
/// Distinct physical cores, counting an SMT sibling pair once.
///
/// `/proc/cpuinfo` is blank-line-delimited blocks, one per logical CPU. A core is
/// identified by the `(physical id, core id)` pair. Some machines (Raspberry Pi
/// and most non-x86) print neither, and there the `processor` number is the best
/// available identity — one entry, one core. This is a direct port of `sysinfo`'s
/// algorithm, so the dev-dep oracle test can assert exact equality.
pub fn physical_core_count() -> Option<usize> {
#[cfg(target_os = "linux")]
{
use std::collections::HashSet;
let content = std::fs::read_to_string("/proc/cpuinfo").ok()?;
let mut seen: HashSet<String> = HashSet::new();
let (mut core_id, mut physical_id, mut cpu) = (String::new(), String::new(), String::new());
let mut flush = |core_id: &mut String, physical_id: &mut String, cpu: &mut String| {
if !core_id.is_empty() && !physical_id.is_empty() {
seen.insert(format!("{core_id} {physical_id}"));
} else if !cpu.is_empty() {
seen.insert(cpu.clone());
}
core_id.clear();
physical_id.clear();
cpu.clear();
};
// Mirrors sysinfo's `line.splitn(2, ':').last().trim()`: everything after
// the first colon, or the whole line when there is none. The degenerate
// no-colon case is kept identical so the oracle test can assert exact
// equality rather than "close enough".
fn after_colon(line: &str) -> &str {
match line.split_once(':') {
Some((_, rest)) => rest.trim(),
None => line.trim(),
}
}
for line in content.lines() {
if line.is_empty() {
flush(&mut core_id, &mut physical_id, &mut cpu);
} else if line.starts_with("processor") {
cpu = after_colon(line).to_string();
} else if line.starts_with("core id") {
core_id = after_colon(line).to_string();
} else if line.starts_with("physical id") {
physical_id = after_colon(line).to_string();
}
}
flush(&mut core_id, &mut physical_id, &mut cpu);
if seen.is_empty() { None } else { Some(seen.len()) }
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
/// Logical CPUs the kernel exposes — `processor` lines in `/proc/cpuinfo`, which
/// is what `sysinfo::System::cpus().len()` counts.
///
/// Falls back to [`std::thread::available_parallelism`] (and finally 1) so this
/// never returns 0; a 0 here would divide the whole pipeline into nothing.
pub fn logical_cpu_count() -> usize {
#[cfg(target_os = "linux")]
if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
let n = content
.lines()
.filter(|l| l.starts_with("processor") && l.contains(':'))
.count();
if n > 0 {
return n;
}
}
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}