khive_runtime/resource.rs
1//! Process-level resource reads (ADR-103 Stage 1).
2//!
3//! Cumulative CPU time and RSS via `getrusage(2)`, used by the daemon's
4//! phase-span logging (background-task start/completion/cancellation) and
5//! the `comm.health` resource self-report. `libc` is already a workspace
6//! dependency (used elsewhere in this crate for `flock`/`kill`), so this
7//! reuses it rather than adding a new one, per ADR-103 Stage 1's preference
8//! for a small std/libc-based read over a dedicated sysinfo-style crate.
9
10/// A point-in-time snapshot of this process's cumulative resource usage.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct ProcessResourceUsage {
13 /// Cumulative user+system CPU time consumed by this process since start,
14 /// in microseconds.
15 pub cpu_us: i64,
16 /// Resident set size, in bytes.
17 pub rss_bytes: i64,
18}
19
20/// Read this process's cumulative CPU time and RSS via `getrusage(RUSAGE_SELF)`.
21///
22/// Returns `None` if the underlying syscall fails (never expected in
23/// practice on a supported platform) or on a non-Unix target, where no
24/// portable equivalent is wired up. Callers treat this as a best-effort
25/// diagnostic read, never load-bearing.
26#[cfg(unix)]
27pub fn process_resource_usage() -> Option<ProcessResourceUsage> {
28 // SAFETY: `getrusage` is a POSIX syscall with no memory side effects
29 // beyond writing into the `rusage` out-param, which is a plain
30 // `#[repr(C)]` struct zero-initialized immediately before the call.
31 let usage: libc::rusage = unsafe {
32 let mut usage: libc::rusage = std::mem::zeroed();
33 if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
34 return None;
35 }
36 usage
37 };
38 let user_us = usage.ru_utime.tv_sec * 1_000_000 + i64::from(usage.ru_utime.tv_usec as i32);
39 let sys_us = usage.ru_stime.tv_sec * 1_000_000 + i64::from(usage.ru_stime.tv_usec as i32);
40 // `ru_maxrss` is bytes on macOS/Darwin and KiB on Linux.
41 let rss_bytes: i64 = if cfg!(target_os = "macos") {
42 usage.ru_maxrss as i64
43 } else {
44 usage.ru_maxrss as i64 * 1024
45 };
46 Some(ProcessResourceUsage {
47 cpu_us: user_us + sys_us,
48 rss_bytes,
49 })
50}
51
52/// Non-Unix fallback: no portable `getrusage` equivalent is wired up.
53#[cfg(not(unix))]
54pub fn process_resource_usage() -> Option<ProcessResourceUsage> {
55 None
56}
57
58/// CPU time consumed strictly between two [`ProcessResourceUsage`] snapshots,
59/// in microseconds.
60///
61/// `cpu_us` on a snapshot is cumulative process CPU time since process
62/// start, not CPU consumed by any one phase, so per-phase attribution needs
63/// the delta between an entry and exit snapshot. The result is floored at
64/// zero so a spurious (should-never-happen) decrease in cumulative CPU
65/// between two reads never surfaces as a negative delta.
66pub fn cpu_delta_us(
67 start: Option<ProcessResourceUsage>,
68 end: Option<ProcessResourceUsage>,
69) -> Option<i64> {
70 match (start, end) {
71 (Some(start), Some(end)) => Some(end.cpu_us.saturating_sub(start.cpu_us).max(0)),
72 _ => None,
73 }
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
81 fn process_resource_usage_returns_positive_values() {
82 // Do a small amount of work first so ru_utime/ru_maxrss are
83 // guaranteed non-zero on every platform this runs on.
84 let mut acc: u64 = 0;
85 for i in 0..1_000_000u64 {
86 acc = acc.wrapping_add(i);
87 }
88 std::hint::black_box(acc);
89
90 let usage = process_resource_usage().expect("getrusage must succeed on unix CI runners");
91 assert!(usage.cpu_us >= 0, "cpu_us must be non-negative");
92 assert!(
93 usage.rss_bytes > 0,
94 "rss_bytes must be positive for a live process"
95 );
96 }
97
98 // `cpu_delta_us` must report the difference between two snapshots, not
99 // either snapshot's raw cumulative value. Synthetic snapshots (rather
100 // than real `getrusage` reads) make this fail deterministically if the
101 // caller regresses to reporting `end.cpu_us` directly.
102 #[test]
103 fn cpu_delta_us_reports_the_difference_not_the_cumulative_end_value() {
104 let start = ProcessResourceUsage {
105 cpu_us: 500 * 60 * 1_000_000, // 500 CPU-minutes already burned
106 rss_bytes: 1_000_000,
107 };
108 let end = ProcessResourceUsage {
109 cpu_us: 500 * 60 * 1_000_000 + 1_500, // +1.5ms of CPU during the phase
110 rss_bytes: 1_100_000,
111 };
112
113 let delta = cpu_delta_us(Some(start), Some(end)).expect("both snapshots present");
114 assert_eq!(
115 delta, 1_500,
116 "delta must be end-minus-start, not the cumulative end value"
117 );
118 assert!(
119 delta < start.cpu_us,
120 "a correct delta must be far smaller than the pre-existing cumulative total"
121 );
122 }
123
124 #[test]
125 fn cpu_delta_us_saturates_instead_of_underflowing_on_a_spurious_decrease() {
126 let start = ProcessResourceUsage {
127 cpu_us: 1_000,
128 rss_bytes: 0,
129 };
130 let end = ProcessResourceUsage {
131 cpu_us: 500,
132 rss_bytes: 0,
133 };
134 assert_eq!(cpu_delta_us(Some(start), Some(end)), Some(0));
135 }
136
137 #[test]
138 fn cpu_delta_us_is_none_when_either_snapshot_is_unavailable() {
139 let snap = ProcessResourceUsage {
140 cpu_us: 10,
141 rss_bytes: 0,
142 };
143 assert_eq!(cpu_delta_us(None, Some(snap)), None);
144 assert_eq!(cpu_delta_us(Some(snap), None), None);
145 assert_eq!(cpu_delta_us(None, None), None);
146 }
147}