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/// Resident memory summed over one remembered process and the descendants found while it
82/// lived. Members are counted only while their pid-plus-start-time identity still verifies
83/// after the table snapshot, so a pid recycled during the scan contributes nothing.
84#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
85pub struct TreeUsage {
86    /// The root plus descendants that were still the remembered processes at recheck.
87    pub processes: usize,
88    /// Summed resident (working-set) bytes of the members the OS reported a figure for.
89    pub resident_bytes: u64,
90    /// Members still present but with no readable resident figure.
91    pub unreadable: usize,
92}
93
94/// [`TreeUsage`] for `root` and its current descendants (see
95/// [`AdoptedIdentity::descendants`](crate::AdoptedIdentity::descendants) for which
96/// descendants can be found). A root that is gone or replaced yields an empty usage, not
97/// an error. Member figures come from one [`process_table`](crate::process_table)
98/// snapshot, then every member is verified again before it is counted.
99pub fn tree_usage(root: &crate::AdoptedIdentity) -> io::Result<TreeUsage> {
100    if root.verify()? != crate::IdentityCheck::Same {
101        return Ok(TreeUsage::default());
102    }
103    let mut members = vec![root.clone()];
104    members.extend(root.descendants()?);
105    let table = crate::process_table()?;
106    let mut usage = TreeUsage::default();
107    for member in &members {
108        if member.verify()? != crate::IdentityCheck::Same {
109            continue;
110        }
111        usage.processes += 1;
112        match table
113            .iter()
114            .find(|entry| entry.pid == member.pid())
115            .and_then(|entry| entry.working_set_bytes)
116        {
117            Some(bytes) => usage.resident_bytes = usage.resident_bytes.saturating_add(bytes),
118            None => usage.unreadable += 1,
119        }
120    }
121    Ok(usage)
122}
123
124/// What a process did over a quiet window: the idle figures a runtime budget names.
125#[derive(Clone, Copy, Debug)]
126pub struct IdleObservation {
127    pub elapsed: Duration,
128    pub start: ProcessUsage,
129    pub end: ProcessUsage,
130    pub rates: UsageRates,
131}
132
133/// Sleeps `duration` on the calling thread and reports the process's usage over it. Run it after
134/// the load has stopped; it is for benches and tests, so a host reads [`process_usage`] instead.
135pub fn observe_idle(duration: Duration) -> io::Result<IdleObservation> {
136    let start = process_usage()?;
137    let began = std::time::Instant::now();
138    std::thread::sleep(duration);
139    let end = process_usage()?;
140    let elapsed = began.elapsed();
141    Ok(IdleObservation {
142        elapsed,
143        start,
144        end,
145        rates: end.rates_since(&start, elapsed),
146    })
147}
148
149/// Names and scale of the load behind an [`IdleObservation`], for [`IdleObservation::result_lines`].
150#[derive(Clone, Copy, Debug)]
151pub struct BudgetLabels<'a> {
152    /// Crate being measured, e.g. `rightkit-cache`.
153    pub krate: &'a str,
154    pub version: &'a str,
155    /// Unit of `scale`, e.g. `entries`.
156    pub scale_unit: &'a str,
157    /// How much load the crate carried.
158    pub scale: u64,
159    /// Plain-words description of that load.
160    pub what: &'a str,
161    /// `synthetic`, `real` or `installed`.
162    pub scope: &'a str,
163}
164
165impl IdleObservation {
166    /// The stable machine-readable lines the runtime-budget gate records, one figure per line:
167    /// `RESULT <key>=<number> <unit>`, or `RESULT <key>=unavailable <reason>` when the host cannot
168    /// report the figure (never a stand-in zero). `resident_memory_bytes` is the resident set at the
169    /// end of the idle window. `scale` and `what` state the load the crate carried; `scope` is
170    /// `synthetic`, `real` or `installed`.
171    ///
172    /// `resident_memory_bytes` is the larger of the resident set `warm` (taken right after the load)
173    /// and at the end of the idle window: an OS may compress or page out idle memory, and a ceiling
174    /// must cover the loaded state, not only the reclaimed one. The two are also printed separately.
175    ///
176    /// It also prints the single gate line `RESULT budget crate=.. version=.. resident_bytes=..
177    /// idle_cpu_percent=.. wakeups_per_s=.. disk_ops_per_s=.. scale_unit=.. scale_count=..`
178    /// (docs/RUNTIME-BUDGETS.md); a figure the host cannot report prints `unavailable` there, which
179    /// the gate refuses, and is never replaced by zero.
180    pub fn result_lines(&self, warm: &ProcessUsage, labels: &BudgetLabels<'_>) -> String {
181        let BudgetLabels {
182            krate,
183            version,
184            scale_unit,
185            scale,
186            what,
187            scope,
188        } = *labels;
189        let mut out = String::new();
190        let mut line = |text: String| {
191            out.push_str("RESULT ");
192            out.push_str(&text);
193            out.push('\n');
194        };
195        let resident_max = warm.resident_bytes.max(self.end.resident_bytes);
196        line(format!("warm_resident_bytes={} bytes", warm.resident_bytes));
197        line(format!(
198            "idle_resident_bytes={} bytes",
199            self.end.resident_bytes
200        ));
201        if resident_max > 0 {
202            line(format!("resident_memory_bytes={resident_max} bytes"));
203        } else {
204            line("resident_memory_bytes=unavailable host reported a zero resident set".to_string());
205        }
206        line(format!(
207            "footprint_bytes={} bytes",
208            warm.footprint_bytes.max(self.end.footprint_bytes)
209        ));
210        line(format!(
211            "idle_window_seconds={:.1} seconds",
212            self.elapsed.as_secs_f64()
213        ));
214        line(format!(
215            "idle_cpu_percent={:.3} percent",
216            self.rates.cpu_percent
217        ));
218        let opt = |name: &str,
219                   value: Option<f64>,
220                   unit: &str,
221                   reason: &str,
222                   line: &mut dyn FnMut(String)| match value {
223            Some(v) => line(format!("{name}={v:.3} {unit}")),
224            None => line(format!("{name}=unavailable {reason}")),
225        };
226        opt(
227            "wakeups_per_second",
228            self.rates.wakeups_per_second,
229            "per_second",
230            "the host exposes no process wake-up counter",
231            &mut line,
232        );
233        opt(
234            "disk_ops_per_second",
235            self.rates.disk_ops_per_second,
236            "per_second",
237            "the host exposes no process disk-operation counter",
238            &mut line,
239        );
240        opt(
241            "disk_read_bytes_per_second",
242            self.rates.disk_read_bytes_per_second,
243            "bytes_per_second",
244            "the host exposes no process disk-byte counter",
245            &mut line,
246        );
247        opt(
248            "disk_write_bytes_per_second",
249            self.rates.disk_write_bytes_per_second,
250            "bytes_per_second",
251            "the host exposes no process disk-byte counter",
252            &mut line,
253        );
254        line(format!("scale={scale} {what}"));
255        line(format!("scope={scope}"));
256        let num =
257            |v: Option<f64>| v.map_or_else(|| "unavailable".to_string(), |v| format!("{v:.3}"));
258        let resident = if resident_max > 0 {
259            resident_max.to_string()
260        } else {
261            "unavailable".to_string()
262        };
263        line(format!(
264            "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}",
265            self.rates.cpu_percent,
266            num(self.rates.wakeups_per_second),
267            num(self.rates.disk_ops_per_second),
268        ));
269        out
270    }
271}
272
273#[cfg(target_os = "macos")]
274mod imp {
275    use super::ProcessUsage;
276    use std::io;
277
278    extern "C" {
279        fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut u8) -> i32;
280    }
281
282    /// RUSAGE_INFO_V2 words after the 16-byte uuid (offsets from the SDK's libproc.h
283    /// `struct rusage_info_v2`).
284    const W_INTERRUPT_WKUPS: usize = 2 + 3;
285    const W_RESIDENT: usize = 2 + 6;
286    const W_FOOTPRINT: usize = 2 + 7;
287    const W_DISK_READ: usize = 2 + 16;
288    const W_DISK_WRITE: usize = 2 + 17;
289
290    pub fn sample() -> io::Result<ProcessUsage> {
291        let mut words = [0u64; 64];
292        // RUSAGE_INFO_V2 = 2.
293        let rc = unsafe { proc_pid_rusage(libc::getpid(), 2, words.as_mut_ptr().cast()) };
294        if rc != 0 {
295            return Err(io::Error::last_os_error());
296        }
297        let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
298        if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
299            return Err(io::Error::last_os_error());
300        }
301        let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
302        Ok(ProcessUsage {
303            resident_bytes: words[W_RESIDENT],
304            footprint_bytes: words[W_FOOTPRINT],
305            cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
306            wakeups_total: Some(words[W_INTERRUPT_WKUPS]),
307            disk_ops_total: None,
308            disk_read_bytes: Some(words[W_DISK_READ]),
309            disk_write_bytes: Some(words[W_DISK_WRITE]),
310        })
311    }
312}
313
314#[cfg(all(
315    unix,
316    not(target_os = "macos"),
317    not(any(target_os = "linux", target_os = "android"))
318))]
319mod imp {
320    use super::ProcessUsage;
321    use std::io;
322
323    /// Chosen when the crate is compiled for a Unix this module has no sampler for: an
324    /// explicit, typed error rather than a procfs read that may not exist.
325    pub fn sample() -> io::Result<ProcessUsage> {
326        Err(io::Error::new(
327            io::ErrorKind::Unsupported,
328            "process usage sampling is implemented for macOS, Linux, Android and Windows only",
329        ))
330    }
331}
332
333#[cfg(any(target_os = "linux", target_os = "android"))]
334mod imp {
335    use super::ProcessUsage;
336    use std::io;
337
338    pub fn sample() -> io::Result<ProcessUsage> {
339        let statm = std::fs::read_to_string("/proc/self/statm")?;
340        let pages: u64 = statm
341            .split_whitespace()
342            .nth(1)
343            .and_then(|v| v.parse().ok())
344            .ok_or_else(|| {
345                io::Error::new(io::ErrorKind::InvalidData, "unreadable /proc/self/statm")
346            })?;
347        let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(1))
348            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "page size out of range"))?;
349        let overflow = || io::Error::new(io::ErrorKind::InvalidData, "memory size overflows u64");
350        let resident = pages.checked_mul(page).ok_or_else(overflow)?;
351        // Block counters are signed C longs (32-bit on 32-bit targets): widen before adding.
352        let blocks = |count: libc::c_long| u64::try_from(count.max(0)).unwrap_or(0);
353        let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
354        if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
355            return Err(io::Error::last_os_error());
356        }
357        let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
358        Ok(ProcessUsage {
359            resident_bytes: resident,
360            footprint_bytes: resident,
361            cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
362            wakeups_total: None,
363            disk_ops_total: blocks(ru.ru_inblock).checked_add(blocks(ru.ru_oublock)),
364            disk_read_bytes: None,
365            disk_write_bytes: None,
366        })
367    }
368}
369
370#[cfg(windows)]
371mod imp {
372    use super::ProcessUsage;
373    use std::io;
374    use windows::Wdk::System::SystemInformation::{
375        NtQuerySystemInformation, SystemProcessInformation,
376    };
377    use windows::Win32::Foundation::{FILETIME, STATUS_INFO_LENGTH_MISMATCH};
378    use windows::Win32::System::ProcessStatus::{
379        GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
380    };
381    use windows::Win32::System::Threading::{
382        GetCurrentProcess, GetCurrentProcessId, GetProcessIoCounters, GetProcessTimes, IO_COUNTERS,
383    };
384    use windows::Win32::System::WindowsProgramming::{
385        SYSTEM_PROCESS_INFORMATION, SYSTEM_THREAD_INFORMATION,
386    };
387
388    fn os(error: windows::core::Error) -> io::Error {
389        io::Error::from_raw_os_error(error.code().0 & 0xFFFF)
390    }
391
392    fn ticks(t: FILETIME) -> u64 {
393        ((t.dwHighDateTime as u64) << 32) | t.dwLowDateTime as u64
394    }
395
396    /// Bytes from the start of a `SYSTEM_PROCESS_INFORMATION` entry to its first
397    /// `SYSTEM_THREAD_INFORMATION` (the Windows 10/11 x64 layout; the `windows` crate's public
398    /// struct is 8 bytes shorter than the real one, so `size_of` cannot be used).
399    #[cfg(target_pointer_width = "64")]
400    const THREADS_OFFSET: usize = 0x100;
401    /// Upper bound on the process-table buffer.
402    #[cfg(target_pointer_width = "64")]
403    const MAX_QUERY_BYTES: usize = 256 * 1024 * 1024;
404
405    /// Total context switches (switch-ins, one per thread wake) over every live thread of this
406    /// process. `ContextSwitchCount` is the `u32` the crate names `Reserved3` in
407    /// `SYSTEM_THREAD_INFORMATION` (offset 0x40, right after `BasePriority`). `None` on any
408    /// failure or unexpected layout; never panics.
409    #[cfg(target_pointer_width = "64")]
410    fn wakeups_total() -> Option<u64> {
411        // 8-byte aligned backing store; entry offsets are multiples of 8.
412        let mut words: Vec<u64> = vec![0; (1 << 20) / 8];
413        let mut length: u32 = 0;
414        let mut attempts = 0;
415        loop {
416            let capacity = words.len() * 8;
417            let status = unsafe {
418                NtQuerySystemInformation(
419                    SystemProcessInformation,
420                    words.as_mut_ptr().cast(),
421                    u32::try_from(capacity).ok()?,
422                    &mut length,
423                )
424            };
425            if status.0 >= 0 {
426                break;
427            }
428            attempts += 1;
429            if status != STATUS_INFO_LENGTH_MISMATCH || attempts > 8 {
430                return None;
431            }
432            // The process table can grow between calls: ask for a margin over the reported size.
433            let want = (length as usize).checked_add(256 * 1024)?;
434            if want <= capacity || want > MAX_QUERY_BYTES {
435                return None;
436            }
437            words = vec![0; want.div_ceil(8)];
438        }
439        let valid = (length as usize).min(words.len() * 8);
440        let base = words.as_ptr().cast::<u8>();
441        let pid = unsafe { GetCurrentProcessId() } as usize;
442        let thread_size = std::mem::size_of::<SYSTEM_THREAD_INFORMATION>();
443        let mut offset = 0usize;
444        loop {
445            if offset.checked_add(THREADS_OFFSET)? > valid {
446                return None;
447            }
448            // SAFETY: `offset + THREADS_OFFSET <= valid <= buffer size` covers the header read.
449            let entry = unsafe {
450                std::ptr::read_unaligned(base.add(offset).cast::<SYSTEM_PROCESS_INFORMATION>())
451            };
452            if entry.UniqueProcessId.0 as usize == pid {
453                let count = entry.NumberOfThreads as usize;
454                let first = offset + THREADS_OFFSET;
455                let end = first.checked_add(count.checked_mul(thread_size)?)?;
456                if end > valid {
457                    return None;
458                }
459                let mut total = 0u64;
460                for i in 0..count {
461                    // SAFETY: `first + (i + 1) * thread_size <= end <= valid`.
462                    let thread = unsafe {
463                        std::ptr::read_unaligned(
464                            base.add(first + i * thread_size)
465                                .cast::<SYSTEM_THREAD_INFORMATION>(),
466                        )
467                    };
468                    // Layout guard: every thread of this process names this process.
469                    if thread.ClientId.UniqueProcess.0 as usize != pid {
470                        return None;
471                    }
472                    total = total.checked_add(u64::from(thread.Reserved3))?;
473                }
474                return Some(total);
475            }
476            let next = entry.NextEntryOffset as usize;
477            if next == 0 {
478                return None;
479            }
480            offset = offset.checked_add(next)?;
481        }
482    }
483
484    #[cfg(not(target_pointer_width = "64"))]
485    fn wakeups_total() -> Option<u64> {
486        None
487    }
488
489    pub fn sample() -> io::Result<ProcessUsage> {
490        unsafe {
491            let process = GetCurrentProcess();
492            let mut mem = PROCESS_MEMORY_COUNTERS_EX::default();
493            GetProcessMemoryInfo(
494                process,
495                (&mut mem as *mut PROCESS_MEMORY_COUNTERS_EX).cast::<PROCESS_MEMORY_COUNTERS>(),
496                std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
497            )
498            .map_err(os)?;
499            let (mut created, mut exited, mut kernel, mut user) = (
500                FILETIME::default(),
501                FILETIME::default(),
502                FILETIME::default(),
503                FILETIME::default(),
504            );
505            GetProcessTimes(process, &mut created, &mut exited, &mut kernel, &mut user)
506                .map_err(os)?;
507            let mut io_counters = IO_COUNTERS::default();
508            GetProcessIoCounters(process, &mut io_counters).map_err(os)?;
509            Ok(ProcessUsage {
510                resident_bytes: mem.WorkingSetSize as u64,
511                footprint_bytes: mem.PrivateUsage as u64,
512                // FILETIME ticks are 100 ns.
513                cpu_seconds: (ticks(kernel) + ticks(user)) as f64 / 1e7,
514                wakeups_total: wakeups_total(),
515                disk_ops_total: Some(
516                    io_counters.ReadOperationCount + io_counters.WriteOperationCount,
517                ),
518                disk_read_bytes: Some(io_counters.ReadTransferCount),
519                disk_write_bytes: Some(io_counters.WriteTransferCount),
520            })
521        }
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    /// Sampling exists where an implementation does: macOS, Linux, Android and Windows.
530    #[cfg(any(
531        target_os = "macos",
532        target_os = "linux",
533        target_os = "android",
534        windows
535    ))]
536    #[test]
537    fn sample_reports_resident_memory_and_cpu() {
538        let first = process_usage().unwrap();
539        assert!(first.resident_bytes > 0);
540        assert!(first.footprint_bytes > 0);
541        let mut sink = 0u64;
542        for i in 0..20_000_000u64 {
543            sink = sink.wrapping_add(i * 31);
544        }
545        std::hint::black_box(sink);
546        let second = process_usage().unwrap();
547        assert!(second.cpu_seconds >= first.cpu_seconds);
548        let rates = second.rates_since(&first, Duration::from_secs(1));
549        assert!(rates.cpu_percent >= 0.0);
550    }
551
552    /// Windows reports wake-ups as summed thread context switches: present, and not decreasing
553    /// while the threads that did the waking are still alive.
554    #[cfg(all(windows, target_pointer_width = "64"))]
555    #[test]
556    fn windows_wakeups_count_thread_context_switches() {
557        use std::sync::{mpsc, Arc, Mutex};
558        let (tx, rx) = mpsc::channel::<()>();
559        let rx = Arc::new(Mutex::new(rx));
560        let (done_tx, done_rx) = mpsc::channel::<()>();
561        let workers: Vec<_> = (0..3)
562            .map(|_| {
563                let rx = rx.clone();
564                let done = done_tx.clone();
565                std::thread::spawn(move || {
566                    // Each message wakes one worker.
567                    while rx.lock().map(|r| r.recv().is_ok()).unwrap_or(false) {
568                        let _ = done.send(());
569                    }
570                })
571            })
572            .collect();
573        // Other tests in this process may end threads between samples (their counts leave the
574        // sum), so allow a few attempts for a non-decreasing pair.
575        let mut ok = false;
576        for _ in 0..5 {
577            let first = process_usage()
578                .unwrap()
579                .wakeups_total
580                .expect("windows reports wake-ups");
581            for _ in 0..5 {
582                std::thread::sleep(Duration::from_millis(10));
583                tx.send(()).unwrap();
584                done_rx.recv().unwrap();
585            }
586            let second = process_usage()
587                .unwrap()
588                .wakeups_total
589                .expect("windows reports wake-ups");
590            if second >= first {
591                ok = true;
592                break;
593            }
594        }
595        drop(tx);
596        for worker in workers {
597            let _ = worker.join();
598        }
599        assert!(ok, "wake-up total decreased on every attempt");
600    }
601
602    /// Every other Unix target compiles and reports `Unsupported`, never a made-up sample.
603    #[cfg(all(
604        unix,
605        not(target_os = "macos"),
606        not(any(target_os = "linux", target_os = "android"))
607    ))]
608    #[test]
609    fn unsupported_unix_targets_report_unsupported_not_a_sample() {
610        let error = process_usage().expect_err("no sampler exists for this target");
611        assert_eq!(error.kind(), io::ErrorKind::Unsupported);
612        let error = observe_idle(Duration::from_millis(1)).expect_err("idle needs sampling");
613        assert_eq!(error.kind(), io::ErrorKind::Unsupported);
614    }
615
616    #[cfg(any(
617        target_os = "macos",
618        target_os = "linux",
619        target_os = "android",
620        windows
621    ))]
622    #[test]
623    fn tree_usage_counts_the_remembered_root_and_refuses_a_replaced_one() {
624        let pid = std::num::NonZeroU32::new(std::process::id()).unwrap();
625        let root = crate::AdoptedIdentity::capture(pid)
626            .unwrap()
627            .expect("this process has an identity");
628        let usage = tree_usage(&root).unwrap();
629        assert!(usage.processes >= 1, "{usage:?}");
630        assert!(usage.resident_bytes > 0, "{usage:?}");
631        // Same pid, a start token that is not this process's: counted as nobody.
632        let replaced = crate::AdoptedIdentity::from_recorded(
633            pid,
634            crate::StartToken {
635                major: root.start_token().major.wrapping_add(1),
636                minor: root.start_token().minor,
637            },
638            None,
639        );
640        assert_eq!(tree_usage(&replaced).unwrap(), TreeUsage::default());
641    }
642
643    #[test]
644    fn rates_are_none_without_counters_and_zero_for_no_time() {
645        let a = ProcessUsage {
646            wakeups_total: None,
647            ..Default::default()
648        };
649        let b = ProcessUsage {
650            wakeups_total: Some(10),
651            ..Default::default()
652        };
653        assert_eq!(
654            b.rates_since(&a, Duration::from_secs(1)).wakeups_per_second,
655            None
656        );
657        let c = ProcessUsage {
658            wakeups_total: Some(30),
659            ..Default::default()
660        };
661        assert_eq!(
662            c.rates_since(&b, Duration::from_secs(2)).wakeups_per_second,
663            Some(10.0)
664        );
665        assert_eq!(c.rates_since(&b, Duration::ZERO).cpu_percent, 0.0);
666    }
667}