open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/process_cpu.rs
Transaction Science b2e75b9f37 jouleclaw-energy's process-counter test kills and reaps its busy child, however the test ends
tests/process_cpu.rs (macOS) starts a child that spins `while :; do :; done` and kills it
inside the window it meters. Between its spawn and that kill each energy read asserts; a read
that failed there unwound past a bare std::process::Child, which is not killed when it is
dropped, and left the child spinning a core once the test binary had ended, one more for each
failed run. The child is now held, as it is spawned, in a `Running` that kills it and reaps it
when it is dropped, waiting up to 10 s (as wai-quantum-service's tests hold theirs, 04d19cfd8);
the test still kills and reaps it inside the window, so what it meters is unchanged.

Planted fault, on the old-prod Mac Studio (macOS, M3 Ultra): the test made to panic right
after its first read of the child's counter, with the child running, run alone in a session and
process group of its own (POSIX setsid; macOS has no setsid command, and its ps and pgrep
cannot select by session, so the group was listed), 3 s after cargo ended. At main the group
held the busy child (/bin/sh -c while :; do :; done, 3 s old), which was then killed by its
group; with the guard it held nothing.

Gates, on the old-prod Mac Studio: cargo test --locked -p jouleclaw-energy passed (76 tests in
9 binaries) and with --features ioreport (144 in 9), each leaving nothing in its process group;
cargo clippy --locked --all-targets --no-deps -p jouleclaw-energy -- -D warnings is clean in
the default build and with ioreport. The light checks pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 12:35:59 -05:00

219 lines
10 KiB
Rust

//! This process's own CPU-energy counter on real hardware, asserted in a process
//! of its own.
//!
//! The counter reads every thread of this process, so a test that bounds it from
//! above cannot share its process with other tests running at the same time:
//! their threads would be in the figure. This file is one test binary with one
//! test, which runs its checks in sequence on one thread. Nothing in it returns
//! early: off macOS it is not compiled, and on macOS a counter that is not live
//! fails it.
#![cfg(target_os = "macos")]
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use jouleclaw_energy::meter::{detect_in_process_meter, detect_meter, EnergyMeter};
use jouleclaw_energy::process::{process_cpu_liveness, ProcessCpuCounter};
use jouleclaw_energy::{CounterLiveness, EnergyCounter, EnergyDomain, EnergyError, Provenance};
/// This thread's CPU time.
fn thread_cpu() -> Duration {
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
// SAFETY: `ts` is a valid, writable timespec for the duration of the call.
let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) };
assert_eq!(rc, 0, "the thread CPU clock");
Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)
}
/// Burn `cpu` of this thread's own CPU time (not wall time, so a loaded host
/// that deschedules the thread still gets the whole burn).
fn burn(cpu: Duration) {
let start = thread_cpu();
let mut x = 1u64;
while thread_cpu() - start < cpu {
for _ in 0..10_000 {
x = std::hint::black_box(x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1));
}
}
}
/// Another process's cumulative CPU energy, in nJ, read independently of the
/// crate: `proc_pid_rusage(pid, RUSAGE_INFO_V6)` into a 464-byte buffer, whose
/// `ri_energy_nj` is the u64 at byte offset 336 (the SDK's layout).
fn energy_nj_of(pid: libc::pid_t) -> u64 {
let mut buf = [0u64; 58];
// SAFETY: `buf` is 464 writable bytes, the size of `rusage_info_v6`.
let rc = unsafe { libc::proc_pid_rusage(pid, 6, buf.as_mut_ptr() as *mut libc::rusage_info_t) };
assert_eq!(rc, 0, "proc_pid_rusage({pid})");
buf[336 / 8]
}
/// How long [`Running`] waits for a process it has killed to be reaped. `SIGKILL` ends a
/// process at once unless it is stuck in the kernel; a drop that waited on such a one without
/// a bound would hang the test binary instead of failing it.
const REAPED_WITHIN: Duration = Duration::from_secs(10);
/// A process the test started, killed and reaped when it is dropped: at the end of the test, or
/// while a failed assertion unwinds through it. A [`Child`] is not killed when it is dropped, so
/// an assertion that failed while the busy child ran left it spinning a core after the test
/// binary had ended, one more for each failed run. It reads as the child it holds.
struct Running(Child);
impl Running {
/// Kills the process and reaps it, waiting up to [`REAPED_WITHIN`]: whether it was reaped.
/// A process already reaped is not signalled again (`Child::kill` does nothing then, as its
/// pid may be another process's by now).
fn stop(&mut self) -> bool {
let _ = self.0.kill();
let t = Instant::now();
loop {
match self.0.try_wait() {
Ok(Some(_)) => return true,
Ok(None) if t.elapsed() < REAPED_WITHIN => std::thread::sleep(Duration::from_millis(10)),
_ => return false,
}
}
}
}
impl std::ops::Deref for Running {
type Target = Child;
fn deref(&self) -> &Child {
&self.0
}
}
impl std::ops::DerefMut for Running {
fn deref_mut(&mut self) -> &mut Child {
&mut self.0
}
}
impl Drop for Running {
fn drop(&mut self) {
if !self.stop() {
eprintln!("process {} was killed and not reaped within {} s", self.0.id(), REAPED_WITHIN.as_secs());
}
}
}
#[test]
fn the_process_counter_meters_this_process_and_nothing_else() {
// 1. Live, probed once per process however many threads ask.
let askers: Vec<_> = (0..4).map(|_| std::thread::spawn(|| ProcessCpuCounter::new().liveness())).collect();
let seen: Vec<CounterLiveness> = askers.into_iter().map(|t| t.join().expect("asker")).collect();
let counter = ProcessCpuCounter::new();
let _ = detect_in_process_meter();
let _ = detect_in_process_meter();
// Counted before anything here asks for the liveness directly: the counters
// and meters above must have run the probe themselves, once.
let runs = jouleclaw_energy::process_cpu_probe_runs();
println!("process-cpu: liveness={:?} seen by 4 threads={seen:?} probe runs={runs}", counter.liveness());
assert_eq!(runs, 1, "the counters did not run the liveness probe exactly once");
assert_eq!(counter.liveness(), CounterLiveness::Live, "this process's CPU-energy counter is not live here");
assert_eq!(process_cpu_liveness(), CounterLiveness::Live);
assert!(seen.iter().all(|l| *l == CounterLiveness::Live), "{seen:?}");
assert_eq!(jouleclaw_energy::process_cpu_probe_runs(), 1, "the liveness probe ran more than once");
assert!(counter.is_available());
let r = counter.read().expect("a live counter reads");
assert_eq!((r.domain, r.provenance), (EnergyDomain::ProcessCpu, Provenance::HwShunt));
assert_eq!((counter.domain(), counter.provenance(), counter.name()), (EnergyDomain::ProcessCpu, Provenance::HwShunt, "process-cpu"));
// 2. 200 ms of one thread's CPU time is a reading, and the reading is this
// process's own energy over the burn: no more than an oracle's window
// around it, and most of it. No joule figure is asserted — what 200 ms
// of CPU costs depends on the core class that ran it (0.099 J on an
// efficiency core at a load average of 114; 0.18-1.24 J otherwise on
// an M5 Max), so a fixed floor fails on the host's load, not on the
// meter. The oracle sleeps before each read: a thread that leaves its
// core has its energy folded into the process's count, so the oracle's
// window holds the whole burn. The counter's own reads may each miss
// up to a scheduling quantum of the reading thread, hence "most of".
// SAFETY: `getpid` has no preconditions.
let me = unsafe { libc::getpid() };
std::thread::sleep(Duration::from_millis(2));
let oracle_before = energy_nj_of(me);
let t = Instant::now();
let ((), burned) = counter.measure(|| burn(Duration::from_millis(200)));
let wall = t.elapsed();
std::thread::sleep(Duration::from_millis(2));
let oracle_nj = energy_nj_of(me) - oracle_before;
let uj = burned.expect("a 200 ms burn is a reading");
println!(
"process-cpu: 200 ms one-thread CPU burn = {:.3} J over {:.0} ms wall ({:.2} J per CPU-second); oracle window {:.3} J",
uj as f64 / 1e6,
wall.as_secs_f64() * 1e3,
uj as f64 / 1e6 / 0.2,
oracle_nj as f64 / 1e9
);
assert!(uj > 0, "a burn read as zero");
assert!(uj * 1_000 <= oracle_nj + 1_000, "the burn ({uj} µJ) exceeds the oracle's window around it ({oracle_nj} nJ)");
assert!(uj * 1_000 * 4 >= oracle_nj * 3, "the burn ({uj} µJ) is under three quarters of the oracle's window ({oracle_nj} nJ)");
// 3. A 200 ms sleep reads under a tenth of the burn — or, if nothing of
// this process ran, is not a reading at all; never a zero. A counter
// that reads wall time at a fixed power reads the sleep at 200 ms over
// the burn's wall time of the burn's figure — a sixth of it at the
// heaviest load seen here (1.26 s of wall for the burn) — and fails.
let ((), slept) = counter.measure(|| std::thread::sleep(Duration::from_millis(200)));
println!("process-cpu: 200 ms sleep = {slept:?} µJ");
match slept {
Ok(slept_uj) => assert!(slept_uj * 10 < uj, "{slept_uj} µJ for a sleep, against {uj} µJ for the burn"),
Err(EnergyError::NotAdvanced) => {}
Err(e) => panic!("a sleep window failed: {e}"),
}
// 4. `detect_meter` does not include it; `detect_in_process_meter` selects it.
let names = detect_meter().names();
println!("process-cpu: detect_meter holds {names:?}, in-process meter is {}", detect_in_process_meter().active_name());
assert!(!names.contains(&"process-cpu"), "{names:?}");
assert_ne!(detect_meter().active_name(), "process-cpu");
assert_eq!(detect_in_process_meter().active_name(), "process-cpu");
// 5. A child process's work is not attributed: a child burns CPU while this
// process sleeps, and is killed and reaped inside the window. The child's
// own counter, read independently, shows the work happened. The child
// runs until its own counter has read 0.2 J (at least 300 ms, at most
// 5 s), not for a fixed 300 ms: on a heavily loaded host a fixed 300 ms
// got the child too little CPU (0.076 J at a load average of 170), and
// the test failed on the host's load, not on the meter. The child is
// held as it starts, so a read that fails while it runs kills it too.
let mut child = Running(
Command::new("/bin/sh")
.args(["-c", "while :; do :; done"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn a busy child"),
);
let pid = child.id() as libc::pid_t;
let child_before = energy_nj_of(pid);
let started = Instant::now();
let (child_burned_nj, parent) = counter.measure(|| {
let mut burned = 0;
while started.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(50));
burned = energy_nj_of(pid) - child_before;
if burned >= 200_000_000 && started.elapsed() >= Duration::from_millis(300) {
break;
}
}
child.kill().expect("kill the child");
child.wait().expect("reap the child");
burned
});
println!(
"process-cpu: child burned {:.3} J in {:?}; this process's window = {parent:?} µJ",
child_burned_nj as f64 / 1e9,
started.elapsed()
);
assert!(child_burned_nj >= 200_000_000, "the child did not burn: {child_burned_nj} nJ");
match parent {
// Under a tenth of what the child burned: this process only polled.
Ok(uj) => assert!(uj * 1_000 * 10 < child_burned_nj, "{uj} µJ in this process while only the child worked ({child_burned_nj} nJ)"),
Err(EnergyError::NotAdvanced) => {}
Err(e) => panic!("the parent window failed: {e}"),
}
}