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
 */

//! Linux backend, reading CPU power from the RAPL powercap interface and GPU
//! power from the vendor command-line tools.

use crate::config::AppMatch;
use crate::platform::counter::{Interval, WrappingCounter, repeat_failure, warn_unavailable};
use crate::platform::cpu_usage::AppMonitor;
use crate::platform::gpu::VendorGpu;
use crate::platform::procfs::{self, MAX_KERNEL_FILE_BYTES, read_capped};
use crate::sensor::{
    AppCpuUtilization, CpuUtilization, Platform, PowerSensor, ProcessCpuUtilization,
};
use crate::{Error, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;

/// Where the kernel exposes RAPL energy counters.
const POWERCAP_PATH: &str = "/sys/class/powercap";

/// How this sensor is named in errors.
const SENSOR: &str = "Intel RAPL";

/// The Linux backend.
#[derive(Debug, Default)]
pub(crate) struct LinuxPlatform;

impl Platform for LinuxPlatform {
    fn cpu(&self) -> Box<dyn PowerSensor> {
        Box::new(RaplSensor::new(Rapl::open()))
    }

    fn gpu(&self) -> Box<dyn PowerSensor> {
        Box::new(VendorGpu)
    }

    fn cpu_usage(&self) -> Box<dyn CpuUtilization> {
        Box::new(procfs::cpu_usage())
    }

    fn process_cpu_usage(&self) -> Option<Box<dyn ProcessCpuUtilization>> {
        Some(procfs::process_tracker())
    }

    fn app_cpu_usage(
        &self,
        refresh_interval: Duration,
        app_match: AppMatch,
    ) -> Option<Box<dyn AppCpuUtilization>> {
        Some(AppMonitor::boxed(
            procfs::process_tracker,
            refresh_interval,
            app_match,
        ))
    }
}

/// One RAPL package domain and its last reading.
#[derive(Debug)]
struct RaplDomain {
    energy_path: PathBuf,
    counter: WrappingCounter,
}

impl RaplDomain {
    /// Energy consumed since the previous call, in joules.
    fn energy_delta(&mut self) -> Result<f64> {
        let current = read_joules(&self.energy_path)?;
        Ok(self.counter.delta(current))
    }
}

/// The RAPL package domains of this machine.
///
/// Multi-socket machines expose one domain per package; all of them have to be
/// summed or the reported figure only covers the first socket.
#[derive(Debug)]
struct Rapl {
    domains: Vec<RaplDomain>,
}

impl Rapl {
    /// Discover every readable package domain.
    ///
    /// Fails if no package domain exists, or if the energy counters exist but
    /// cannot be read — which is the default on kernels since 5.10, where they
    /// are root-only. Both are reported at construction rather than surfacing
    /// later as a stream of zero-watt readings.
    fn open() -> Result<Self> {
        Self::open_under(Path::new(POWERCAP_PATH))
    }

    /// Discover package domains below `root`, which is [`POWERCAP_PATH`] in
    /// every build and a fixture directory under test.
    fn open_under(root: &Path) -> Result<Self> {
        let mut domains = Vec::new();
        let mut permission_error = None;

        for entry in package_domain_paths(root) {
            let energy_path = entry.join("energy_uj");

            match read_joules(&energy_path) {
                Ok(last_energy) => {
                    let max_energy = read_joules(&entry.join("max_energy_range_uj"))?;
                    domains.push(RaplDomain {
                        energy_path,
                        counter: WrappingCounter::new(max_energy, last_energy),
                    });
                }
                Err(e @ Error::PermissionDenied { .. }) => permission_error = Some(e),
                Err(e) => {
                    log::debug!("skipping RAPL domain {}: {e}", energy_path.display());
                }
            }
        }

        if let Some(e) = permission_error.filter(|_| domains.is_empty()) {
            return Err(e);
        }
        if domains.is_empty() {
            return Err(Error::sensor(
                SENSOR,
                format!("no readable package domain under {}", root.display()),
            ));
        }

        Ok(Self { domains })
    }
}

/// CPU power, as RAPL joules over the time they took to accumulate.
struct RaplSensor {
    /// The open counters, or the reason they could not be opened — which is
    /// then reported on every read rather than once at construction.
    rapl: Result<Rapl>,
    interval: Interval,
}

impl RaplSensor {
    fn new(rapl: Result<Rapl>) -> Self {
        warn_unavailable(SENSOR, &rapl);
        Self {
            rapl,
            interval: Interval::default(),
        }
    }
}

impl PowerSensor for RaplSensor {
    fn power(&mut self) -> Result<f64> {
        let rapl = match &mut self.rapl {
            Ok(rapl) => rapl,
            Err(e) => return Err(repeat_failure(SENSOR, e)),
        };

        let mut joules = 0.0;
        for domain in &mut rapl.domains {
            joules += domain.energy_delta()?;
        }

        // The first reading only establishes the baseline, and it has to be
        // taken even though it is discarded: skipping it would leave the
        // counters without a value to difference against.
        let Some(seconds) = self.interval.tick() else {
            return Ok(0.0);
        };

        Ok(joules / seconds)
    }
}

/// Paths of every `intel-rapl:N` package domain below `root`, in discovery
/// order.
fn package_domain_paths(root: &Path) -> Vec<PathBuf> {
    let Ok(entries) = fs::read_dir(root) else {
        return Vec::new();
    };

    let mut paths: Vec<PathBuf> = entries
        .flatten()
        .map(|entry| entry.path())
        .filter(|path| {
            // Sub-domains such as intel-rapl:0:0 (core, uncore, dram) are
            // already counted inside their package, so only top-level
            // `intel-rapl:N` entries are taken.
            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
                return false;
            };
            let Some(suffix) = name.strip_prefix("intel-rapl:") else {
                return false;
            };
            suffix.chars().all(|c| c.is_ascii_digit())
                && read_capped(&path.join("name"), MAX_KERNEL_FILE_BYTES)
                    .is_ok_and(|name| name.trim().starts_with("package-"))
        })
        .collect();

    // read_dir order is unspecified; sorting keeps logs and domain indices
    // stable between runs.
    paths.sort();
    paths
}

/// Read a microjoule counter as joules.
fn read_joules(path: &Path) -> Result<f64> {
    let raw = read_capped(path, MAX_KERNEL_FILE_BYTES).map_err(|e| {
        if e.kind() == std::io::ErrorKind::PermissionDenied {
            Error::permission(
                SENSOR,
                format!(
                    "{} is not readable; run as root or grant read access \
                     (kernels since 5.10 restrict RAPL counters)",
                    path.display()
                ),
            )
        } else {
            Error::sensor(SENSOR, format!("{}: {e}", path.display()))
        }
    })?;

    let microjoules: f64 = raw.trim().parse().map_err(|e| {
        Error::sensor(
            SENSOR,
            format!("{} does not hold a number: {e}", path.display()),
        )
    })?;

    Ok(microjoules / 1_000_000.0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    /// Build a fake `/sys/class/powercap` tree.
    ///
    /// Each entry is `(directory name, name file, energy_uj, max_energy_range_uj)`;
    /// a `None` counter means the file is left out entirely.
    fn powercap(entries: &[(&str, &str, Option<&str>, &str)]) -> TempDir {
        let root = TempDir::new().expect("temp dir");
        for (dir, name, energy, max_energy) in entries {
            let domain = root.path().join(dir);
            fs::create_dir(&domain).expect("create domain");
            fs::write(domain.join("name"), name).expect("write name");
            if let Some(energy) = energy {
                fs::write(domain.join("energy_uj"), energy).expect("write energy");
            }
            fs::write(domain.join("max_energy_range_uj"), max_energy).expect("write max");
        }
        root
    }

    #[test]
    fn only_top_level_package_domains_are_summed() {
        // Sub-domains are already counted inside their package, and non-package
        // domains (psys) measure something else entirely.
        let root = powercap(&[
            ("intel-rapl:0", "package-0", Some("1000000"), "60000000"),
            ("intel-rapl:1", "package-1", Some("2000000"), "60000000"),
            ("intel-rapl:0:0", "core", Some("500000"), "60000000"),
            ("intel-rapl:2", "psys", Some("900000"), "60000000"),
            ("not-a-rapl-domain", "package-9", Some("100000"), "60000000"),
        ]);

        let paths = package_domain_paths(root.path());
        let names: Vec<_> = paths
            .iter()
            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
            .collect();
        assert_eq!(names, ["intel-rapl:0", "intel-rapl:1"]);
    }

    #[test]
    fn a_tree_with_no_package_domain_is_rejected() {
        let root = powercap(&[("intel-rapl:0", "psys", Some("1000000"), "60000000")]);
        let error = Rapl::open_under(root.path()).unwrap_err();
        assert!(
            error.to_string().contains("no readable package domain"),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn a_missing_powercap_tree_is_rejected_rather_than_panicking() {
        let error = Rapl::open_under(Path::new("/definitely/not/here")).unwrap_err();
        assert!(matches!(error, Error::SensorUnavailable { .. }));
    }

    /// The sensor a caller actually gets, over a fixture powercap tree.
    fn sensor(root: &Path) -> RaplSensor {
        RaplSensor::new(Rapl::open_under(root))
    }

    #[test]
    fn power_is_energy_over_time_across_every_package() {
        let root = powercap(&[
            ("intel-rapl:0", "package-0", Some("1000000"), "60000000"),
            ("intel-rapl:1", "package-1", Some("2000000"), "60000000"),
        ]);
        let mut sensor = sensor(root.path());

        // The first reading only establishes the baseline.
        assert_eq!(sensor.power().unwrap(), 0.0);

        // Advance both counters by 1 J each; power is 2 J over the elapsed time,
        // which is small and positive.
        fs::write(root.path().join("intel-rapl:0/energy_uj"), "2000000").unwrap();
        fs::write(root.path().join("intel-rapl:1/energy_uj"), "3000000").unwrap();
        let watts = sensor.power().unwrap();
        assert!(watts > 0.0, "expected positive power, got {watts}");
    }

    #[test]
    fn a_wrapped_counter_does_not_report_negative_power() {
        // max_energy_range_uj is 60 J, and the counter drops from 59 J to 1 J:
        // that is a 2 J interval, not a -58 J one.
        let root = powercap(&[("intel-rapl:0", "package-0", Some("59000000"), "60000000")]);
        let mut sensor = sensor(root.path());
        assert_eq!(sensor.power().unwrap(), 0.0);

        fs::write(root.path().join("intel-rapl:0/energy_uj"), "1000000").unwrap();
        assert!(sensor.power().unwrap() > 0.0);
    }

    #[test]
    fn a_junk_wrap_point_is_reported_rather_than_guessed() {
        // Without a usable wrap point a counter rollover cannot be told from a
        // reset, so this fails loudly instead of inventing a modulus.
        let root = powercap(&[("intel-rapl:0", "package-0", Some("1000000"), "not a number")]);
        let error = Rapl::open_under(root.path()).unwrap_err();
        assert!(
            error.to_string().contains("does not hold a number"),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn an_unreadable_domain_is_skipped_rather_than_failing_the_others() {
        // A junk or missing energy counter only disqualifies its own package:
        // a dual-socket machine with one bad domain still reports the other.
        let root = powercap(&[
            ("intel-rapl:0", "package-0", None, "60000000"),
            ("intel-rapl:1", "package-1", Some("junk"), "60000000"),
            ("intel-rapl:2", "package-2", Some("2000000"), "60000000"),
        ]);
        assert!(Rapl::open_under(root.path()).is_ok());
    }
}