Skip to main content

rightkit_child_process/
usage.rs

1//! Resource usage of the current process: the one shared source for the figures a runtime budget
2//! names (docs/RUNTIME-BUDGETS.md): resident memory, CPU time, wake-ups and disk operations.
3//!
4//! Computed on call from the operating system; no thread, no polling, no state. A host or a crate's
5//! own `status()` calls [`process_usage`]; two snapshots give rates through [`ProcessUsage::rates_since`].
6//!
7//! | Figure | macOS | Windows | Linux, Android |
8//! |---|---|---|---|
9//! | `resident_bytes` | `proc_pid_rusage` `ri_resident_size` | `WorkingSetSize` | `/proc/self/statm` |
10//! | `footprint_bytes` | `ri_phys_footprint` | `PrivateUsage` | same as resident |
11//! | `cpu_seconds` | `getrusage(RUSAGE_SELF)` user + system | `GetProcessTimes` user + kernel | `getrusage` |
12//! | `wakeups_total` | `ri_interrupt_wkups` (thread wake-ups from timer and other interrupts; `ri_pkg_idle_wkups` stays 0 whenever the machine is busy, so it cannot be used) | not available | not available |
13//! | `disk_ops_total` | not available: `ru_inblock`/`ru_oublock` stay 0 on macOS and no per-process operation count exists; bytes are reported instead | `GetProcessIoCounters` read + write operation counts (all I/O, including pipes and sockets) | `ru_inblock + ru_oublock` |
14//!
15//! Other Unix targets compile, and `process_usage` returns `ErrorKind::Unsupported` there.
16//!
17//! A figure the host cannot report is `None`; it is never reported as zero.
18
19use std::io;
20use std::time::Duration;
21
22/// One snapshot of the current process's resource usage.
23#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub struct ProcessUsage {
25    /// Resident set (working set on Windows) in bytes.
26    pub resident_bytes: u64,
27    /// Private footprint in bytes: macOS `phys_footprint`, Windows `PrivateUsage`.
28    pub footprint_bytes: u64,
29    /// Process CPU time (user plus system) since process start, in seconds.
30    pub cpu_seconds: f64,
31    /// Thread wake-ups (timer and other interrupts) since process start, when the host reports them.
32    pub wakeups_total: Option<u64>,
33    /// Disk (block or I/O) operations since process start, when the host reports them.
34    pub disk_ops_total: Option<u64>,
35    /// Bytes read from storage since process start, when the host reports them.
36    pub disk_read_bytes: Option<u64>,
37    /// Bytes written to storage since process start, when the host reports them.
38    pub disk_write_bytes: Option<u64>,
39}
40
41/// Rates between two snapshots. Each rate is `None` when either snapshot lacks the counter.
42#[derive(Clone, Copy, Debug, Default, PartialEq)]
43pub struct UsageRates {
44    /// Process CPU percent of one core (100 = one full core) over the interval.
45    pub cpu_percent: f64,
46    pub wakeups_per_second: Option<f64>,
47    pub disk_ops_per_second: Option<f64>,
48    pub disk_read_bytes_per_second: Option<f64>,
49    pub disk_write_bytes_per_second: Option<f64>,
50}
51
52impl ProcessUsage {
53    /// Rates of change from `earlier` (an older snapshot) to `self` over `elapsed`.
54    /// A zero `elapsed` yields zero rates rather than dividing by zero.
55    pub fn rates_since(&self, earlier: &ProcessUsage, elapsed: Duration) -> UsageRates {
56        let secs = elapsed.as_secs_f64();
57        let rate = |now: Option<u64>, then: Option<u64>| match (now, then) {
58            (Some(now), Some(then)) if secs > 0.0 => Some(now.saturating_sub(then) as f64 / secs),
59            (Some(_), Some(_)) => Some(0.0),
60            _ => None,
61        };
62        UsageRates {
63            cpu_percent: if secs > 0.0 {
64                (self.cpu_seconds - earlier.cpu_seconds).max(0.0) / secs * 100.0
65            } else {
66                0.0
67            },
68            wakeups_per_second: rate(self.wakeups_total, earlier.wakeups_total),
69            disk_ops_per_second: rate(self.disk_ops_total, earlier.disk_ops_total),
70            disk_read_bytes_per_second: rate(self.disk_read_bytes, earlier.disk_read_bytes),
71            disk_write_bytes_per_second: rate(self.disk_write_bytes, earlier.disk_write_bytes),
72        }
73    }
74}
75
76/// Resource usage of the current process, computed now.
77pub fn process_usage() -> io::Result<ProcessUsage> {
78    imp::sample()
79}
80
81/// What a process did over a quiet window: the idle figures a runtime budget names.
82#[derive(Clone, Copy, Debug)]
83pub struct IdleObservation {
84    pub elapsed: Duration,
85    pub start: ProcessUsage,
86    pub end: ProcessUsage,
87    pub rates: UsageRates,
88}
89
90/// Sleeps `duration` on the calling thread and reports the process's usage over it. Run it after
91/// the load has stopped; it is for benches and tests, so a host reads [`process_usage`] instead.
92pub fn observe_idle(duration: Duration) -> io::Result<IdleObservation> {
93    let start = process_usage()?;
94    let began = std::time::Instant::now();
95    std::thread::sleep(duration);
96    let end = process_usage()?;
97    let elapsed = began.elapsed();
98    Ok(IdleObservation {
99        elapsed,
100        start,
101        end,
102        rates: end.rates_since(&start, elapsed),
103    })
104}
105
106/// Names and scale of the load behind an [`IdleObservation`], for [`IdleObservation::result_lines`].
107#[derive(Clone, Copy, Debug)]
108pub struct BudgetLabels<'a> {
109    /// Crate being measured, e.g. `rightkit-cache`.
110    pub krate: &'a str,
111    pub version: &'a str,
112    /// Unit of `scale`, e.g. `entries`.
113    pub scale_unit: &'a str,
114    /// How much load the crate carried.
115    pub scale: u64,
116    /// Plain-words description of that load.
117    pub what: &'a str,
118    /// `synthetic`, `real` or `installed`.
119    pub scope: &'a str,
120}
121
122impl IdleObservation {
123    /// The stable machine-readable lines the runtime-budget gate records, one figure per line:
124    /// `RESULT <key>=<number> <unit>`, or `RESULT <key>=unavailable <reason>` when the host cannot
125    /// report the figure (never a stand-in zero). `resident_memory_bytes` is the resident set at the
126    /// end of the idle window. `scale` and `what` state the load the crate carried; `scope` is
127    /// `synthetic`, `real` or `installed`.
128    ///
129    /// `resident_memory_bytes` is the larger of the resident set `warm` (taken right after the load)
130    /// and at the end of the idle window: an OS may compress or page out idle memory, and a ceiling
131    /// must cover the loaded state, not only the reclaimed one. The two are also printed separately.
132    ///
133    /// It also prints the single gate line `RESULT budget crate=.. version=.. resident_bytes=..
134    /// idle_cpu_percent=.. wakeups_per_s=.. disk_ops_per_s=.. scale_unit=.. scale_count=..`
135    /// (docs/RUNTIME-BUDGETS.md); a figure the host cannot report prints `unavailable` there, which
136    /// the gate refuses, and is never replaced by zero.
137    pub fn result_lines(&self, warm: &ProcessUsage, labels: &BudgetLabels<'_>) -> String {
138        let BudgetLabels {
139            krate,
140            version,
141            scale_unit,
142            scale,
143            what,
144            scope,
145        } = *labels;
146        let mut out = String::new();
147        let mut line = |text: String| {
148            out.push_str("RESULT ");
149            out.push_str(&text);
150            out.push('\n');
151        };
152        let resident_max = warm.resident_bytes.max(self.end.resident_bytes);
153        line(format!("warm_resident_bytes={} bytes", warm.resident_bytes));
154        line(format!(
155            "idle_resident_bytes={} bytes",
156            self.end.resident_bytes
157        ));
158        if resident_max > 0 {
159            line(format!("resident_memory_bytes={resident_max} bytes"));
160        } else {
161            line("resident_memory_bytes=unavailable host reported a zero resident set".to_string());
162        }
163        line(format!(
164            "footprint_bytes={} bytes",
165            warm.footprint_bytes.max(self.end.footprint_bytes)
166        ));
167        line(format!(
168            "idle_window_seconds={:.1} seconds",
169            self.elapsed.as_secs_f64()
170        ));
171        line(format!(
172            "idle_cpu_percent={:.3} percent",
173            self.rates.cpu_percent
174        ));
175        let opt = |name: &str,
176                   value: Option<f64>,
177                   unit: &str,
178                   reason: &str,
179                   line: &mut dyn FnMut(String)| match value {
180            Some(v) => line(format!("{name}={v:.3} {unit}")),
181            None => line(format!("{name}=unavailable {reason}")),
182        };
183        opt(
184            "wakeups_per_second",
185            self.rates.wakeups_per_second,
186            "per_second",
187            "the host exposes no process wake-up counter",
188            &mut line,
189        );
190        opt(
191            "disk_ops_per_second",
192            self.rates.disk_ops_per_second,
193            "per_second",
194            "the host exposes no process disk-operation counter",
195            &mut line,
196        );
197        opt(
198            "disk_read_bytes_per_second",
199            self.rates.disk_read_bytes_per_second,
200            "bytes_per_second",
201            "the host exposes no process disk-byte counter",
202            &mut line,
203        );
204        opt(
205            "disk_write_bytes_per_second",
206            self.rates.disk_write_bytes_per_second,
207            "bytes_per_second",
208            "the host exposes no process disk-byte counter",
209            &mut line,
210        );
211        line(format!("scale={scale} {what}"));
212        line(format!("scope={scope}"));
213        let num =
214            |v: Option<f64>| v.map_or_else(|| "unavailable".to_string(), |v| format!("{v:.3}"));
215        let resident = if resident_max > 0 {
216            resident_max.to_string()
217        } else {
218            "unavailable".to_string()
219        };
220        line(format!(
221            "budget crate={krate} version={version} resident_bytes={resident} idle_cpu_percent={:.3} wakeups_per_s={} disk_ops_per_s={} scale_unit={scale_unit} scale_count={scale}",
222            self.rates.cpu_percent,
223            num(self.rates.wakeups_per_second),
224            num(self.rates.disk_ops_per_second),
225        ));
226        out
227    }
228}
229
230#[cfg(target_os = "macos")]
231mod imp {
232    use super::ProcessUsage;
233    use std::io;
234
235    extern "C" {
236        fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut u8) -> i32;
237    }
238
239    /// RUSAGE_INFO_V2 words after the 16-byte uuid (offsets from the SDK's libproc.h
240    /// `struct rusage_info_v2`).
241    const W_INTERRUPT_WKUPS: usize = 2 + 3;
242    const W_RESIDENT: usize = 2 + 6;
243    const W_FOOTPRINT: usize = 2 + 7;
244    const W_DISK_READ: usize = 2 + 16;
245    const W_DISK_WRITE: usize = 2 + 17;
246
247    pub fn sample() -> io::Result<ProcessUsage> {
248        let mut words = [0u64; 64];
249        // RUSAGE_INFO_V2 = 2.
250        let rc = unsafe { proc_pid_rusage(libc::getpid(), 2, words.as_mut_ptr().cast()) };
251        if rc != 0 {
252            return Err(io::Error::last_os_error());
253        }
254        let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
255        if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
256            return Err(io::Error::last_os_error());
257        }
258        let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
259        Ok(ProcessUsage {
260            resident_bytes: words[W_RESIDENT],
261            footprint_bytes: words[W_FOOTPRINT],
262            cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
263            wakeups_total: Some(words[W_INTERRUPT_WKUPS]),
264            disk_ops_total: None,
265            disk_read_bytes: Some(words[W_DISK_READ]),
266            disk_write_bytes: Some(words[W_DISK_WRITE]),
267        })
268    }
269}
270
271#[cfg(all(
272    unix,
273    not(target_os = "macos"),
274    not(any(target_os = "linux", target_os = "android"))
275))]
276mod imp {
277    use super::ProcessUsage;
278    use std::io;
279
280    /// Chosen when the crate is compiled for a Unix this module has no sampler for: an
281    /// explicit, typed error rather than a procfs read that may not exist.
282    pub fn sample() -> io::Result<ProcessUsage> {
283        Err(io::Error::new(
284            io::ErrorKind::Unsupported,
285            "process usage sampling is implemented for macOS, Linux, Android and Windows only",
286        ))
287    }
288}
289
290#[cfg(any(target_os = "linux", target_os = "android"))]
291mod imp {
292    use super::ProcessUsage;
293    use std::io;
294
295    pub fn sample() -> io::Result<ProcessUsage> {
296        let statm = std::fs::read_to_string("/proc/self/statm")?;
297        let pages: u64 = statm
298            .split_whitespace()
299            .nth(1)
300            .and_then(|v| v.parse().ok())
301            .ok_or_else(|| {
302                io::Error::new(io::ErrorKind::InvalidData, "unreadable /proc/self/statm")
303            })?;
304        let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(1))
305            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "page size out of range"))?;
306        let overflow = || io::Error::new(io::ErrorKind::InvalidData, "memory size overflows u64");
307        let resident = pages.checked_mul(page).ok_or_else(overflow)?;
308        // Block counters are signed C longs (32-bit on 32-bit targets): widen before adding.
309        let blocks = |count: libc::c_long| u64::try_from(count.max(0)).unwrap_or(0);
310        let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
311        if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
312            return Err(io::Error::last_os_error());
313        }
314        let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
315        Ok(ProcessUsage {
316            resident_bytes: resident,
317            footprint_bytes: resident,
318            cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
319            wakeups_total: None,
320            disk_ops_total: blocks(ru.ru_inblock).checked_add(blocks(ru.ru_oublock)),
321            disk_read_bytes: None,
322            disk_write_bytes: None,
323        })
324    }
325}
326
327#[cfg(windows)]
328mod imp {
329    use super::ProcessUsage;
330    use std::io;
331    use windows::Win32::Foundation::FILETIME;
332    use windows::Win32::System::ProcessStatus::{
333        GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
334    };
335    use windows::Win32::System::Threading::{
336        GetCurrentProcess, GetProcessIoCounters, GetProcessTimes, IO_COUNTERS,
337    };
338
339    fn os(error: windows::core::Error) -> io::Error {
340        io::Error::from_raw_os_error(error.code().0 & 0xFFFF)
341    }
342
343    fn ticks(t: FILETIME) -> u64 {
344        ((t.dwHighDateTime as u64) << 32) | t.dwLowDateTime as u64
345    }
346
347    pub fn sample() -> io::Result<ProcessUsage> {
348        unsafe {
349            let process = GetCurrentProcess();
350            let mut mem = PROCESS_MEMORY_COUNTERS_EX::default();
351            GetProcessMemoryInfo(
352                process,
353                (&mut mem as *mut PROCESS_MEMORY_COUNTERS_EX).cast::<PROCESS_MEMORY_COUNTERS>(),
354                std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
355            )
356            .map_err(os)?;
357            let (mut created, mut exited, mut kernel, mut user) = (
358                FILETIME::default(),
359                FILETIME::default(),
360                FILETIME::default(),
361                FILETIME::default(),
362            );
363            GetProcessTimes(process, &mut created, &mut exited, &mut kernel, &mut user)
364                .map_err(os)?;
365            let mut io_counters = IO_COUNTERS::default();
366            GetProcessIoCounters(process, &mut io_counters).map_err(os)?;
367            Ok(ProcessUsage {
368                resident_bytes: mem.WorkingSetSize as u64,
369                footprint_bytes: mem.PrivateUsage as u64,
370                // FILETIME ticks are 100 ns.
371                cpu_seconds: (ticks(kernel) + ticks(user)) as f64 / 1e7,
372                wakeups_total: None,
373                disk_ops_total: Some(
374                    io_counters.ReadOperationCount + io_counters.WriteOperationCount,
375                ),
376                disk_read_bytes: Some(io_counters.ReadTransferCount),
377                disk_write_bytes: Some(io_counters.WriteTransferCount),
378            })
379        }
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    /// Sampling exists where an implementation does: macOS, Linux, Android and Windows.
388    #[cfg(any(
389        target_os = "macos",
390        target_os = "linux",
391        target_os = "android",
392        windows
393    ))]
394    #[test]
395    fn sample_reports_resident_memory_and_cpu() {
396        let first = process_usage().unwrap();
397        assert!(first.resident_bytes > 0);
398        assert!(first.footprint_bytes > 0);
399        let mut sink = 0u64;
400        for i in 0..20_000_000u64 {
401            sink = sink.wrapping_add(i * 31);
402        }
403        std::hint::black_box(sink);
404        let second = process_usage().unwrap();
405        assert!(second.cpu_seconds >= first.cpu_seconds);
406        let rates = second.rates_since(&first, Duration::from_secs(1));
407        assert!(rates.cpu_percent >= 0.0);
408    }
409
410    /// Every other Unix target compiles and reports `Unsupported`, never a made-up sample.
411    #[cfg(all(
412        unix,
413        not(target_os = "macos"),
414        not(any(target_os = "linux", target_os = "android"))
415    ))]
416    #[test]
417    fn unsupported_unix_targets_report_unsupported_not_a_sample() {
418        let error = process_usage().expect_err("no sampler exists for this target");
419        assert_eq!(error.kind(), io::ErrorKind::Unsupported);
420        let error = observe_idle(Duration::from_millis(1)).expect_err("idle needs sampling");
421        assert_eq!(error.kind(), io::ErrorKind::Unsupported);
422    }
423
424    #[test]
425    fn rates_are_none_without_counters_and_zero_for_no_time() {
426        let a = ProcessUsage {
427            wakeups_total: None,
428            ..Default::default()
429        };
430        let b = ProcessUsage {
431            wakeups_total: Some(10),
432            ..Default::default()
433        };
434        assert_eq!(
435            b.rates_since(&a, Duration::from_secs(1)).wakeups_per_second,
436            None
437        );
438        let c = ProcessUsage {
439            wakeups_total: Some(30),
440            ..Default::default()
441        };
442        assert_eq!(
443            c.rates_since(&b, Duration::from_secs(2)).wakeups_per_second,
444            Some(10.0)
445        );
446        assert_eq!(c.rates_since(&b, Duration::ZERO).cpu_percent, 0.0);
447    }
448}