znippy-common 0.9.14

Core logic and data structures for Znippy, a parallel chunked compression system.
Documentation
//! 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)
}