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) | sum of `ContextSwitchCount` over the live threads of this process (`NtQuerySystemInformation(SystemProcessInformation)`); a thread is switched in once per wake. A thread that exits takes its count with it, so the sum can dip when threads end; 64-bit only | 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::Wdk::System::SystemInformation::{
332        NtQuerySystemInformation, SystemProcessInformation,
333    };
334    use windows::Win32::Foundation::{FILETIME, STATUS_INFO_LENGTH_MISMATCH};
335    use windows::Win32::System::ProcessStatus::{
336        GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
337    };
338    use windows::Win32::System::Threading::{
339        GetCurrentProcess, GetCurrentProcessId, GetProcessIoCounters, GetProcessTimes, IO_COUNTERS,
340    };
341    use windows::Win32::System::WindowsProgramming::{
342        SYSTEM_PROCESS_INFORMATION, SYSTEM_THREAD_INFORMATION,
343    };
344
345    fn os(error: windows::core::Error) -> io::Error {
346        io::Error::from_raw_os_error(error.code().0 & 0xFFFF)
347    }
348
349    fn ticks(t: FILETIME) -> u64 {
350        ((t.dwHighDateTime as u64) << 32) | t.dwLowDateTime as u64
351    }
352
353    /// Bytes from the start of a `SYSTEM_PROCESS_INFORMATION` entry to its first
354    /// `SYSTEM_THREAD_INFORMATION` (the Windows 10/11 x64 layout; the `windows` crate's public
355    /// struct is 8 bytes shorter than the real one, so `size_of` cannot be used).
356    #[cfg(target_pointer_width = "64")]
357    const THREADS_OFFSET: usize = 0x100;
358    /// Upper bound on the process-table buffer.
359    #[cfg(target_pointer_width = "64")]
360    const MAX_QUERY_BYTES: usize = 256 * 1024 * 1024;
361
362    /// Total context switches (switch-ins, one per thread wake) over every live thread of this
363    /// process. `ContextSwitchCount` is the `u32` the crate names `Reserved3` in
364    /// `SYSTEM_THREAD_INFORMATION` (offset 0x40, right after `BasePriority`). `None` on any
365    /// failure or unexpected layout; never panics.
366    #[cfg(target_pointer_width = "64")]
367    fn wakeups_total() -> Option<u64> {
368        // 8-byte aligned backing store; entry offsets are multiples of 8.
369        let mut words: Vec<u64> = vec![0; (1 << 20) / 8];
370        let mut length: u32 = 0;
371        let mut attempts = 0;
372        loop {
373            let capacity = words.len() * 8;
374            let status = unsafe {
375                NtQuerySystemInformation(
376                    SystemProcessInformation,
377                    words.as_mut_ptr().cast(),
378                    u32::try_from(capacity).ok()?,
379                    &mut length,
380                )
381            };
382            if status.0 >= 0 {
383                break;
384            }
385            attempts += 1;
386            if status != STATUS_INFO_LENGTH_MISMATCH || attempts > 8 {
387                return None;
388            }
389            // The process table can grow between calls: ask for a margin over the reported size.
390            let want = (length as usize).checked_add(256 * 1024)?;
391            if want <= capacity || want > MAX_QUERY_BYTES {
392                return None;
393            }
394            words = vec![0; want.div_ceil(8)];
395        }
396        let valid = (length as usize).min(words.len() * 8);
397        let base = words.as_ptr().cast::<u8>();
398        let pid = unsafe { GetCurrentProcessId() } as usize;
399        let thread_size = std::mem::size_of::<SYSTEM_THREAD_INFORMATION>();
400        let mut offset = 0usize;
401        loop {
402            if offset.checked_add(THREADS_OFFSET)? > valid {
403                return None;
404            }
405            // SAFETY: `offset + THREADS_OFFSET <= valid <= buffer size` covers the header read.
406            let entry = unsafe {
407                std::ptr::read_unaligned(base.add(offset).cast::<SYSTEM_PROCESS_INFORMATION>())
408            };
409            if entry.UniqueProcessId.0 as usize == pid {
410                let count = entry.NumberOfThreads as usize;
411                let first = offset + THREADS_OFFSET;
412                let end = first.checked_add(count.checked_mul(thread_size)?)?;
413                if end > valid {
414                    return None;
415                }
416                let mut total = 0u64;
417                for i in 0..count {
418                    // SAFETY: `first + (i + 1) * thread_size <= end <= valid`.
419                    let thread = unsafe {
420                        std::ptr::read_unaligned(
421                            base.add(first + i * thread_size)
422                                .cast::<SYSTEM_THREAD_INFORMATION>(),
423                        )
424                    };
425                    // Layout guard: every thread of this process names this process.
426                    if thread.ClientId.UniqueProcess.0 as usize != pid {
427                        return None;
428                    }
429                    total = total.checked_add(u64::from(thread.Reserved3))?;
430                }
431                return Some(total);
432            }
433            let next = entry.NextEntryOffset as usize;
434            if next == 0 {
435                return None;
436            }
437            offset = offset.checked_add(next)?;
438        }
439    }
440
441    #[cfg(not(target_pointer_width = "64"))]
442    fn wakeups_total() -> Option<u64> {
443        None
444    }
445
446    pub fn sample() -> io::Result<ProcessUsage> {
447        unsafe {
448            let process = GetCurrentProcess();
449            let mut mem = PROCESS_MEMORY_COUNTERS_EX::default();
450            GetProcessMemoryInfo(
451                process,
452                (&mut mem as *mut PROCESS_MEMORY_COUNTERS_EX).cast::<PROCESS_MEMORY_COUNTERS>(),
453                std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
454            )
455            .map_err(os)?;
456            let (mut created, mut exited, mut kernel, mut user) = (
457                FILETIME::default(),
458                FILETIME::default(),
459                FILETIME::default(),
460                FILETIME::default(),
461            );
462            GetProcessTimes(process, &mut created, &mut exited, &mut kernel, &mut user)
463                .map_err(os)?;
464            let mut io_counters = IO_COUNTERS::default();
465            GetProcessIoCounters(process, &mut io_counters).map_err(os)?;
466            Ok(ProcessUsage {
467                resident_bytes: mem.WorkingSetSize as u64,
468                footprint_bytes: mem.PrivateUsage as u64,
469                // FILETIME ticks are 100 ns.
470                cpu_seconds: (ticks(kernel) + ticks(user)) as f64 / 1e7,
471                wakeups_total: wakeups_total(),
472                disk_ops_total: Some(
473                    io_counters.ReadOperationCount + io_counters.WriteOperationCount,
474                ),
475                disk_read_bytes: Some(io_counters.ReadTransferCount),
476                disk_write_bytes: Some(io_counters.WriteTransferCount),
477            })
478        }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    /// Sampling exists where an implementation does: macOS, Linux, Android and Windows.
487    #[cfg(any(
488        target_os = "macos",
489        target_os = "linux",
490        target_os = "android",
491        windows
492    ))]
493    #[test]
494    fn sample_reports_resident_memory_and_cpu() {
495        let first = process_usage().unwrap();
496        assert!(first.resident_bytes > 0);
497        assert!(first.footprint_bytes > 0);
498        let mut sink = 0u64;
499        for i in 0..20_000_000u64 {
500            sink = sink.wrapping_add(i * 31);
501        }
502        std::hint::black_box(sink);
503        let second = process_usage().unwrap();
504        assert!(second.cpu_seconds >= first.cpu_seconds);
505        let rates = second.rates_since(&first, Duration::from_secs(1));
506        assert!(rates.cpu_percent >= 0.0);
507    }
508
509    /// Windows reports wake-ups as summed thread context switches: present, and not decreasing
510    /// while the threads that did the waking are still alive.
511    #[cfg(all(windows, target_pointer_width = "64"))]
512    #[test]
513    fn windows_wakeups_count_thread_context_switches() {
514        use std::sync::{mpsc, Arc, Mutex};
515        let (tx, rx) = mpsc::channel::<()>();
516        let rx = Arc::new(Mutex::new(rx));
517        let (done_tx, done_rx) = mpsc::channel::<()>();
518        let workers: Vec<_> = (0..3)
519            .map(|_| {
520                let rx = rx.clone();
521                let done = done_tx.clone();
522                std::thread::spawn(move || {
523                    // Each message wakes one worker.
524                    while rx.lock().map(|r| r.recv().is_ok()).unwrap_or(false) {
525                        let _ = done.send(());
526                    }
527                })
528            })
529            .collect();
530        // Other tests in this process may end threads between samples (their counts leave the
531        // sum), so allow a few attempts for a non-decreasing pair.
532        let mut ok = false;
533        for _ in 0..5 {
534            let first = process_usage()
535                .unwrap()
536                .wakeups_total
537                .expect("windows reports wake-ups");
538            for _ in 0..5 {
539                std::thread::sleep(Duration::from_millis(10));
540                tx.send(()).unwrap();
541                done_rx.recv().unwrap();
542            }
543            let second = process_usage()
544                .unwrap()
545                .wakeups_total
546                .expect("windows reports wake-ups");
547            if second >= first {
548                ok = true;
549                break;
550            }
551        }
552        drop(tx);
553        for worker in workers {
554            let _ = worker.join();
555        }
556        assert!(ok, "wake-up total decreased on every attempt");
557    }
558
559    /// Every other Unix target compiles and reports `Unsupported`, never a made-up sample.
560    #[cfg(all(
561        unix,
562        not(target_os = "macos"),
563        not(any(target_os = "linux", target_os = "android"))
564    ))]
565    #[test]
566    fn unsupported_unix_targets_report_unsupported_not_a_sample() {
567        let error = process_usage().expect_err("no sampler exists for this target");
568        assert_eq!(error.kind(), io::ErrorKind::Unsupported);
569        let error = observe_idle(Duration::from_millis(1)).expect_err("idle needs sampling");
570        assert_eq!(error.kind(), io::ErrorKind::Unsupported);
571    }
572
573    #[test]
574    fn rates_are_none_without_counters_and_zero_for_no_time() {
575        let a = ProcessUsage {
576            wakeups_total: None,
577            ..Default::default()
578        };
579        let b = ProcessUsage {
580            wakeups_total: Some(10),
581            ..Default::default()
582        };
583        assert_eq!(
584            b.rates_since(&a, Duration::from_secs(1)).wakeups_per_second,
585            None
586        );
587        let c = ProcessUsage {
588            wakeups_total: Some(30),
589            ..Default::default()
590        };
591        assert_eq!(
592            c.rates_since(&b, Duration::from_secs(2)).wakeups_per_second,
593            Some(10.0)
594        );
595        assert_eq!(c.rates_since(&b, Duration::ZERO).cpu_percent, 0.0);
596    }
597}