jcp-energy: a counter this process cannot read is not an absent counter #328

Merged
dcharlot merged 1 commit from fix/rapl-permission-vs-absent into main 2026-10-19 11:12:29 -04:00

View file

@ -771,6 +771,98 @@ fn rapl_status_on(open: impl FnOnce() -> Option<Rapl>) -> 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<String>,
) -> 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<FabricCapability> {
}
// ── 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<FabricCapability> {
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<String> {
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<String> {
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<String> {
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