joularcore 0.2.0

Joular Core is a platform to measure power and energy across all systems, OSes and devices
Documentation
/*
 * Copyright (c) 2025-2026, Adel Noureddine.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the
 * GNU Lesser General Public License v3.0 only (LGPL-3.0-only)
 * which accompanies this distribution, and is available at
 * https://www.gnu.org/licenses/lgpl-3.0.en.html
 *
 * Author : Adel Noureddine
 */

//! Reading the kernel-generated files under `/proc` and `/sys`, and the CPU
//! time counters built from them. Shared by the Linux and SBC backends.

use crate::platform::cpu_usage::{CpuTimeDelta, TotalsSampler};
use crate::sensor::{CpuTotals, ProcessCpuUtilization};
use std::fs::File;
use std::io::{self, Read};
use std::path::{Path, PathBuf};

/// Most a `/proc` or `/sys` file may hold before the read is refused.
///
/// The largest thing this crate reads from a kernel filesystem is `/proc/stat`,
/// which is a few kilobytes even on a machine with hundreds of cores.
pub(crate) const MAX_KERNEL_FILE_BYTES: u64 = 1024 * 1024;

/// Read a kernel file as text, refusing to buffer more than `max_bytes`.
///
/// These paths are constants pointing into `/proc` and `/sys`, so a file that
/// runs to megabytes means the path is not what we think it is — a symlink or a
/// bind mount into something else. Capping the read keeps that case from
/// exhausting memory.
///
/// Reads one byte past the limit so an oversized file is reported as an error
/// rather than silently truncated: a half-read counter would parse as a
/// plausible but wrong number.
pub(crate) fn read_capped(path: &Path, max_bytes: u64) -> io::Result<String> {
    let mut contents = String::new();
    File::open(path)?
        .take(max_bytes.saturating_add(1))
        .read_to_string(&mut contents)?;

    if contents.len() as u64 > max_bytes {
        return Err(io::Error::other(format!(
            "{} holds more than the {max_bytes} byte limit",
            path.display()
        )));
    }

    Ok(contents)
}

/// Whole-system CPU utilization read from `/proc/stat`.
pub(crate) fn cpu_usage() -> TotalsSampler {
    TotalsSampler::new(read_proc_stat)
}

/// A per-process tracker reading `/proc/<pid>/stat`.
pub(crate) fn process_tracker() -> Box<dyn ProcessCpuUtilization> {
    Box::new(ProcfsProcessTracker::default())
}

/// CPU utilization of a single process, from `/proc/<pid>/stat` over
/// `/proc/stat`.
#[derive(Debug, Default)]
struct ProcfsProcessTracker {
    delta: CpuTimeDelta,
}

impl ProcessCpuUtilization for ProcfsProcessTracker {
    fn process_cpu_utilization(&mut self, pid: u32, cpu_total: Option<u64>) -> f64 {
        // Prefer the total the monitor measured, so the process share and the
        // system figure it is compared against cover the same interval.
        let Some(cpu_total) = cpu_total.or_else(system_cpu_total) else {
            return 0.0;
        };
        let Some(process_time) = pid_cpu_time(pid) else {
            return 0.0;
        };

        self.delta.share(cpu_total, process_time)
    }
}

/// Read the aggregate `cpu` line of `/proc/stat`.
fn read_proc_stat() -> Option<CpuTotals> {
    let raw = read_capped(Path::new("/proc/stat"), MAX_KERNEL_FILE_BYTES).ok()?;
    parse_proc_stat(&raw)
}

/// Cumulative system-wide CPU time, for when the caller has no reading of its
/// own to share.
fn system_cpu_total() -> Option<u64> {
    read_proc_stat().map(|totals| totals.total)
}

/// Parse the aggregate `cpu` line out of `/proc/stat` contents.
fn parse_proc_stat(content: &str) -> Option<CpuTotals> {
    let line = content.lines().next()?;
    if !line.starts_with("cpu ") {
        return None;
    }

    // user, nice, system, idle, iowait, irq, softirq, steal
    let fields: Vec<u64> = line
        .split_whitespace()
        .skip(1)
        .map(|field| field.parse().unwrap_or(0))
        .collect();
    if fields.len() < 8 {
        return None;
    }

    let total = fields
        .iter()
        .take(8)
        .try_fold(0u64, |a, &b| a.checked_add(b))?;
    // The CPU is waiting rather than computing in both idle and iowait.
    let idle = fields[3] + fields[4];

    Some(CpuTotals::new(total, idle))
}

/// Read `utime + stime` for `pid`, in clock ticks.
fn pid_cpu_time(pid: u32) -> Option<u64> {
    let path = PathBuf::from(format!("/proc/{pid}/stat"));
    let content = read_capped(&path, MAX_KERNEL_FILE_BYTES).ok()?;

    // The comm field is parenthesised and may itself contain spaces and
    // parentheses, so fields are counted from the last ')'.
    let after_comm = &content[content.rfind(')')? + 1..];
    let fields: Vec<&str> = after_comm.split_whitespace().collect();

    // After comm: state, ppid, ..., utime (index 11), stime (index 12).
    let utime: u64 = fields.get(11)?.parse().ok()?;
    let stime: u64 = fields.get(12)?.parse().ok()?;

    Some(utime + stime)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write as _;

    const SAMPLE: &str = "cpu  100 20 30 400 50 6 7 8 0 0\ncpu0 1 2 3 4 5 6 7 8 0 0\n";

    #[test]
    fn parses_the_aggregate_cpu_line() {
        let totals = parse_proc_stat(SAMPLE).unwrap();
        assert_eq!(totals.total, 100 + 20 + 30 + 400 + 50 + 6 + 7 + 8);
        assert_eq!(totals.idle, 400 + 50);
    }

    #[test]
    fn guest_columns_beyond_the_first_eight_are_ignored() {
        // guest and guest_nice are already counted inside user and nice.
        let with_guest = "cpu  100 20 30 400 50 6 7 8 900 900\n";
        assert_eq!(parse_proc_stat(with_guest).unwrap().total, 621);
    }

    #[test]
    fn rejects_input_that_is_not_proc_stat() {
        assert!(parse_proc_stat("").is_none());
        assert!(parse_proc_stat("intr 1 2 3\n").is_none());
        // Fewer than the eight required columns.
        assert!(parse_proc_stat("cpu  1 2 3\n").is_none());
    }

    #[test]
    fn unparsable_columns_count_as_zero_rather_than_failing() {
        let totals = parse_proc_stat("cpu  100 x 30 400 50 6 7 8\n").unwrap();
        assert_eq!(totals.total, 601);
    }

    fn file_holding(contents: &str) -> tempfile::NamedTempFile {
        let mut file = tempfile::NamedTempFile::new().expect("temp file");
        file.write_all(contents.as_bytes()).expect("write");
        file.flush().expect("flush");
        file
    }

    #[test]
    fn reads_a_file_that_fits() {
        let file = file_holding("12345\n");
        assert_eq!(read_capped(file.path(), 64).unwrap(), "12345\n");
    }

    #[test]
    fn a_file_exactly_at_the_limit_still_reads() {
        let file = file_holding("abcd");
        assert_eq!(read_capped(file.path(), 4).unwrap(), "abcd");
    }

    #[test]
    fn an_oversized_file_is_an_error_rather_than_a_truncated_read() {
        // Truncating is the dangerous outcome: "123456" cut to "123" parses
        // fine and reports a counter an order of magnitude too small.
        let file = file_holding("123456");
        let error = read_capped(file.path(), 3).unwrap_err();
        assert!(
            error.to_string().contains("more than the 3 byte limit"),
            "unexpected message: {error}"
        );
    }

    #[test]
    fn a_missing_path_keeps_its_io_error_kind() {
        // Callers branch on `ErrorKind`, so the original must survive.
        let error = read_capped(Path::new("/proc/definitely-not-here"), 64).unwrap_err();
        assert_eq!(error.kind(), io::ErrorKind::NotFound);
    }

    #[test]
    fn a_process_that_does_not_exist_reports_nothing() {
        // PID 0 never exists, so this exercises the unreadable-process path.
        assert_eq!(pid_cpu_time(0), None);
    }
}