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

2026-10-18

Transaction Science
jcp-energy: a counter this process cannot read is not an absent counter
All checks were successful
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 47s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 7s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 58s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m54s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m14s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m12s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m16s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 2m55s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 3m58s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m7s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 6m50s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m29s
`survey()` exists to distinguish the ways an energy counter can be missing,
because they are not the same kind of gap: `NoCounterExists` is a property of
the hardware and is the status that makes a disclosure exemption legitimate,
while `NeedsPrivilege` is "a legitimate gap, and a fixable one".

The RAPL row could not produce `NeedsPrivilege` at all. `Rapl::open_in` maps
every failure to `None` — its own doc says so, "non-Linux, locked-down sysfs,
no Intel/AMD RAPL" — and `survey()` mapped that single `None` to
`FabricStatus::Absent`, documented as "This host has no such silicon".

Linux has restricted `energy_uj` to privileged readers since the power
side-channel disclosures of 2020. Checked on a Linux host in this fleet:

    440 root:energy  /sys/class/powercap/intel-rapl:0/energy_uj
    440 root:energy  /sys/class/powercap/intel-rapl:0:0/energy_uj

Mode 0440 root:energy, so a process outside that group reads `EACCES` from a
file that is plainly there. The survey called that an absence of hardware.

`RaplAccess` asks the access question separately from the liveness question:
`Readable`, `NeedsPrivilege`, or `NoInterface`. `rapl_fabric_status` maps the
two answers to a status, consulting accessibility first — the probe cannot
observe a counter it cannot open, so a liveness answer beside an unreadable
counter says nothing about the hardware. A permission failure on any one
package zone decides the answer even beside a readable one, which follows
`Rapl::open_in`'s existing rule that a figure claims the whole CPU and so is
every package zone or none.

This is a consistency repair, not a new idea: `probe_path`'s doc already states
the rule — "a presence probe, never a reading... so the survey can say
NotImplemented — our gap — instead of Absent, which would be a false claim
about the host" — and every vendor accelerator row follows it. The RAPL row was
the one that did not.

`FabricStatus::Absent` now says what it actually asserts. It is used for
`(fabric, source)` pairs that are not available here, including a source that
is not an interface to the fabric on this platform (`ri_energy_nj` off macOS,
RAPL off x86 Linux) — where the silicon is plainly present. So it makes no
claim that the fabric is unmeasurable by another route and, unlike
`NoCounterExists`, excuses nothing.

The status mapping is a free function rather than inline in `survey()` because
`survey()` reads the real `/sys/class/powercap` and so cannot be tested on a
host without one. That left the mapping — the entire point of asking about
accessibility — unpinned: falsification caught it, because a mutation flipping
`NeedsPrivilege` back to `Absent` broke no test whatsoever. Taking both answers
as arguments makes every combination checkable anywhere, and a mutation
restoring the exact pre-fix behaviour now fails.

Permissions are injected in tests rather than applied with `chmod`, which would
prove nothing in a container running as root where every mode is readable. The
headline test is the discriminating pair: the same fake tree read two ways,
`Readable` and `NeedsPrivilege`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-18 19:52:32 -04:00