Skip to main content

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}