From dbb9f2a787e9e199b14aed97331b760c81e66103 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 23:03:30 +0200 Subject: [PATCH] stats(p3): normalise cpu% against host CPU count, not process affinity Per review: available_parallelism() respects the hive-core process's CPU affinity, so if it's ever affinity-pinned the denominator would under-count and inflate cpu_pct. Read the online host CPUs from /sys/devices/system/cpu/online instead (fall back to the process count, then 1) so the 'percent of total host CPU' definition holds regardless. --- hive-c0re/src/container_stats.rs | 33 +++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/hive-c0re/src/container_stats.rs b/hive-c0re/src/container_stats.rs index 20b25138..ea488da4 100644 --- a/hive-c0re/src/container_stats.rs +++ b/hive-c0re/src/container_stats.rs @@ -91,6 +91,37 @@ fn read_usage_usec(dir: &std::path::Path) -> Option { None } +/// Total online host CPUs, for normalising CPU% to "% of total host". +/// Reads `/sys/devices/system/cpu/online` (a comma-separated list of +/// ranges, e.g. `0-19`) rather than `available_parallelism()`, which +/// reflects the hive-core process's CPU affinity — if hive-core is ever +/// affinity-pinned to a subset, that would under-count the denominator +/// and inflate the percentage. Falls back to the process count, then 1. +fn host_nproc() -> usize { + fn from_sysfs() -> Option { + let s = std::fs::read_to_string("/sys/devices/system/cpu/online").ok()?; + let mut count = 0usize; + for part in s.trim().split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + if let Some((a, b)) = part.split_once('-') { + let a: usize = a.trim().parse().ok()?; + let b: usize = b.trim().parse().ok()?; + count += b.saturating_sub(a) + 1; + } else { + part.parse::().ok()?; + count += 1; + } + } + (count > 0).then_some(count) + } + from_sysfs() + .or_else(|| std::thread::available_parallelism().ok().map(usize::from)) + .unwrap_or(1) +} + /// Sample CPU + memory for every running agent container. Best-effort: /// agents whose scope dir is absent (not running) or unreadable are /// skipped, never fatal. Sorted by name for a stable display order. @@ -111,7 +142,7 @@ pub async fn gather() -> Vec { sleep(CPU_SAMPLE).await; #[allow(clippy::cast_precision_loss)] - let nproc = std::thread::available_parallelism().map_or(1.0, |n| n.get() as f64); + let nproc = host_nproc() as f64; #[allow(clippy::cast_precision_loss)] let interval_usec = CPU_SAMPLE.as_micros() as f64;