diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index a724bdf40..e84fe99a4 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -771,6 +771,98 @@ fn rapl_status_on(open: impl FnOnce() -> Option) -> CounterStatus { ) } +/// Whether this process can **read** this host's RAPL counter, as distinct +/// from whether the counter is advancing. +/// +/// [`Rapl::open`] answers neither question on its own: it returns `None` for a +/// host with no powercap tree, a host whose package zone is root-only, and a +/// host whose counter is garbled alike. Those are different facts about the +/// world, and one of them is a fixable permission gap while another is an +/// absence of hardware — so [`survey`] needs them apart before it can report a +/// status honestly. +/// +/// Linux has restricted `energy_uj` to privileged readers since the power +/// side-channel disclosures of 2020. A representative host carries +/// `mode 0440 root:energy`, so a process outside that group reads `EACCES` — +/// **not** `ENOENT`. The file is plainly there. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RaplAccess { + /// A package zone is here and this process can read its `energy_uj`. + /// + /// This says nothing about the counter advancing; that is + /// [`rapl_status`]'s question. + Readable, + /// A package zone is here and its `energy_uj` cannot be read for want of + /// privilege. The counter exists and this process may not read it. + NeedsPrivilege, + /// No package zone is reachable here at all. + NoInterface, +} + +/// This host's RAPL accessibility. +#[must_use] +pub fn rapl_access() -> RaplAccess { + rapl_access_in(std::path::Path::new("/sys/class/powercap")) +} + +/// [`rapl_access`] on the powercap tree at `root`, so tests run on fake trees. +fn rapl_access_in(root: &std::path::Path) -> RaplAccess { + rapl_access_with(root, |p| std::fs::read_to_string(p)) +} + +/// [`rapl_access`] over an injected file reader, so a permission error can be +/// tested without `chmod` — which would prove nothing in a container running +/// as root, where every mode is readable. +/// +/// A permission failure on **any** package zone decides the answer, even +/// beside a readable one. That follows [`Rapl::open_in`]'s own rule that a +/// figure claims the whole CPU and so is every package zone or none: where one +/// package cannot be read, the reader does not open, and the reason it did not +/// is privilege. +fn rapl_access_with( + root: &std::path::Path, + read: impl Fn(&std::path::Path) -> std::io::Result, +) -> RaplAccess { + let dir = match std::fs::read_dir(root) { + Ok(dir) => dir, + // The tree itself unreadable for want of privilege is the same finding + // as a zone that is; anything else here is an absent interface. + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + return RaplAccess::NeedsPrivilege; + } + Err(_) => return RaplAccess::NoInterface, + }; + + let mut readable = false; + for entry in dir.flatten() { + let dir_name = entry.file_name(); + if rapl_top_level_zone(&dir_name.to_string_lossy()).is_none() { + continue; + } + match read(&entry.path().join("name")) { + Ok(name) => { + if !is_rapl_package_name(&name) { + continue; + } + } + // A zone this process cannot even identify may be a package, and + // the reason it could not is privilege. + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + return RaplAccess::NeedsPrivilege; + } + Err(_) => continue, + } + match read(&entry.path().join("energy_uj")) { + Ok(_) => readable = true, + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + return RaplAccess::NeedsPrivilege; + } + Err(_) => continue, + } + } + if readable { RaplAccess::Readable } else { RaplAccess::NoInterface } +} + /// How many times the NVML liveness probe has run in this process. Always 0 /// without the `nvml` feature, which has no probe. #[cfg_attr(not(all(test, feature = "nvml")), allow(dead_code))] @@ -6039,7 +6131,15 @@ pub enum FabricStatus { /// advancing — so a figure from it would be a reading of nothing. Apple's /// CPU rails do this for an unprivileged reader. Frozen, - /// This host has no such silicon. + /// This `(fabric, source)` pair is not available here: either the silicon + /// is absent, or this source is not an interface to it on this platform + /// (`ri_energy_nj` off macOS, RAPL off x86 Linux). + /// + /// It makes **no** claim that the fabric is unmeasurable by some other + /// route, and so — unlike [`FabricStatus::NoCounterExists`] — it excuses + /// nothing. Reporting a counter this process merely cannot read as + /// `Absent` would assert an absence of hardware; that is + /// [`FabricStatus::NeedsPrivilege`]. Absent, /// The silicon is here and the counter needs privilege this process does /// not have. A legitimate gap, and a fixable one. @@ -6267,19 +6367,19 @@ pub fn survey() -> Vec { } // ── x86 CPU package energy. + // + // Accessibility is asked **before** liveness, because `Rapl::open` returns + // `None` for a host with no powercap tree and for a host whose package + // zone is root-only alike, and this row previously reported both as + // `Absent` — "this host has no such silicon". On a representative Linux + // host `energy_uj` carries mode 0440 root:energy, so an unprivileged + // reader gets `EACCES` from a file that is plainly there. That is + // `NeedsPrivilege`: a legitimate gap, and a fixable one. push( Fabric::Cpu, FabricSource::Rapl, if cfg!(target_os = "linux") { - match rapl_status() { - CounterStatus::Live => FabricStatus::Live { - attribution: Attribution::Machine, - // The MSR behind powercap updates about every millisecond. - resolution_ns: Some(1_000_000), - }, - CounterStatus::Unavailable => FabricStatus::Absent, - _ => FabricStatus::Frozen, - } + rapl_fabric_status(rapl_access(), rapl_status()) } else { FabricStatus::Absent }, @@ -6384,6 +6484,33 @@ pub fn survey() -> Vec { out } +/// The RAPL row's status, from its accessibility and its liveness. +/// +/// Extracted from [`survey`] because [`survey`] reads the real +/// `/sys/class/powercap` and so cannot be tested on a host that does not have +/// one — which left this mapping, the whole point of asking about +/// accessibility, unpinned by any test. Taking both answers as arguments makes +/// every combination checkable anywhere. +/// +/// Accessibility is asked first: a counter this process cannot read is not an +/// absent counter, and only [`RaplAccess::Readable`] makes the liveness answer +/// meaningful at all — the probe cannot observe a counter it cannot open. +fn rapl_fabric_status(access: RaplAccess, counter: CounterStatus) -> FabricStatus { + match access { + RaplAccess::NeedsPrivilege => FabricStatus::NeedsPrivilege, + RaplAccess::NoInterface => FabricStatus::Absent, + RaplAccess::Readable => match counter { + CounterStatus::Live => FabricStatus::Live { + attribution: Attribution::Machine, + // The MSR behind powercap updates about every millisecond. + resolution_ns: Some(1_000_000), + }, + CounterStatus::Unavailable => FabricStatus::Absent, + _ => FabricStatus::Frozen, + }, + } +} + /// Whether a path exists, used only to decide whether silicon is present. /// /// A presence probe, never a reading: it answers "is there an accelerator @@ -8820,6 +8947,167 @@ mod tests { ); } + /// **A counter this process cannot read is not an absent counter.** + /// + /// `Rapl::open` returns `None` for a missing tree and for a root-only + /// `energy_uj` alike, and `survey` reported both as `Absent` — "this host + /// has no such silicon". On a representative Linux host `energy_uj` + /// carries mode 0440 root:energy, so an unprivileged reader gets `EACCES` + /// from a file that is plainly there. + /// + /// The permission is injected rather than applied with `chmod`, which + /// would prove nothing in a container running as root. + #[test] + fn a_rapl_counter_needing_privilege_is_not_reported_absent() { + let tree = FakePowercap::new(&["1000", "2000"]); + let denied = |p: &std::path::Path| -> std::io::Result { + if p.ends_with("energy_uj") { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "EACCES", + )); + } + std::fs::read_to_string(p) + }; + + // The discriminating pair: the same tree, read two ways. + assert_eq!( + rapl_access_in(&tree.root), + RaplAccess::Readable, + "a readable package zone is readable" + ); + assert_eq!( + rapl_access_with(&tree.root, denied), + RaplAccess::NeedsPrivilege, + "a package zone whose energy_uj is root-only is a privilege gap, not absent hardware" + ); + + // And the fact `Rapl::open` erases: both reads leave it unable to open, + // which is why the access question has to be asked separately. + assert!(Rapl::open_in(&tree.root).is_some(), "the readable tree opens"); + } + + /// The mapping the whole fix turns on, and it was unpinned until a + /// mutation flipping it back to `Absent` broke no test at all. + /// + /// `NeedsPrivilege` must survive **every** liveness answer: the probe + /// cannot observe a counter it cannot open, so a `Unavailable` reading + /// beside an unreadable counter says nothing about the hardware. + #[test] + fn a_privilege_gap_is_reported_as_such_by_the_survey() { + for counter in + [CounterStatus::Live, CounterStatus::Unavailable, CounterStatus::Frozen] + { + assert_eq!( + rapl_fabric_status(RaplAccess::NeedsPrivilege, counter), + FabricStatus::NeedsPrivilege, + "a counter that cannot be read is not absent hardware, whatever \ + the probe says ({counter:?})" + ); + } + + // Only an absent interface is `Absent`. + assert_eq!( + rapl_fabric_status(RaplAccess::NoInterface, CounterStatus::Unavailable), + FabricStatus::Absent + ); + + // And a readable counter is graded on its liveness as before. + assert_eq!( + rapl_fabric_status(RaplAccess::Readable, CounterStatus::Live), + FabricStatus::Live { + attribution: Attribution::Machine, + resolution_ns: Some(1_000_000) + } + ); + assert_eq!( + rapl_fabric_status(RaplAccess::Readable, CounterStatus::Frozen), + FabricStatus::Frozen + ); + assert_eq!( + rapl_fabric_status(RaplAccess::Readable, CounterStatus::Unavailable), + FabricStatus::Absent + ); + + // `NeedsPrivilege` excuses nothing: it is not `NoCounterExists`. + assert_ne!( + rapl_fabric_status(RaplAccess::NeedsPrivilege, CounterStatus::Unavailable), + FabricStatus::NoCounterExists, + "a permission gap must never read as a hardware exemption" + ); + } + + /// A zone this process cannot even identify may be a package, so an + /// unreadable `name` is a privilege gap too rather than a skipped zone. + #[test] + fn a_zone_whose_name_needs_privilege_is_a_privilege_gap() { + let tree = FakePowercap::new(&["1000"]); + let name_denied = |p: &std::path::Path| -> std::io::Result { + if p.ends_with("name") { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "EACCES", + )); + } + std::fs::read_to_string(p) + }; + assert_eq!(rapl_access_with(&tree.root, name_denied), RaplAccess::NeedsPrivilege); + } + + /// One unreadable package beside a readable one still decides the answer: + /// a figure claims the whole CPU, so it is every package zone or none. + #[test] + fn one_package_needing_privilege_decides_the_whole_answer() { + let tree = FakePowercap::new(&["1000", "2000"]); + let one_denied = |p: &std::path::Path| -> std::io::Result { + if p.ends_with("energy_uj") && p.to_string_lossy().contains("intel-rapl:1") { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "EACCES", + )); + } + std::fs::read_to_string(p) + }; + assert_eq!( + rapl_access_with(&tree.root, one_denied), + RaplAccess::NeedsPrivilege, + "a readable package does not excuse one that is not" + ); + } + + /// No tree, and no package zone in a tree, are both an absent interface — + /// which is the one case `Absent` really describes. + #[test] + fn an_absent_interface_is_distinguished_from_a_privilege_gap() { + let tree = FakePowercap::new(&["1000"]); + assert_eq!( + rapl_access_in(&tree.root.join("no-such-dir")), + RaplAccess::NoInterface, + "a missing powercap tree exposes no interface" + ); + + let psys_only = FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("psys"), "90000", Some("262143328850")), + ("intel-rapl:1", Some("dram"), "30000", Some("65532610987")), + ]); + assert_eq!( + rapl_access_in(&psys_only.root), + RaplAccess::NoInterface, + "a tree with no package zone exposes no package interface" + ); + } + + /// Accessibility is not liveness. A readable counter that is frozen is + /// still readable — the two questions have separate answers and separate + /// statuses. + #[test] + fn accessibility_is_not_liveness() { + let frozen = FakePowercap::new(&["1000"]); + assert_eq!(rapl_access_in(&frozen.root), RaplAccess::Readable); + // The counter never moves, so the liveness probe declines it. + assert_eq!(rapl_status_on(|| Rapl::open_in(&frozen.root)), CounterStatus::Frozen); + } + /// No package zone, no RAPL counter: a host exposing only `psys` (and a /// `dram` zone) is Unavailable without a probe, never a figure. Nor does /// the reader open with a package left out: one whose `energy_uj` does