1use std::io;
20use std::time::Duration;
21
22#[derive(Clone, Copy, Debug, Default, PartialEq)]
24pub struct ProcessUsage {
25 pub resident_bytes: u64,
27 pub footprint_bytes: u64,
29 pub cpu_seconds: f64,
31 pub wakeups_total: Option<u64>,
33 pub disk_ops_total: Option<u64>,
35 pub disk_read_bytes: Option<u64>,
37 pub disk_write_bytes: Option<u64>,
39}
40
41#[derive(Clone, Copy, Debug, Default, PartialEq)]
43pub struct UsageRates {
44 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 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
76pub fn process_usage() -> io::Result<ProcessUsage> {
78 imp::sample()
79}
80
81#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
85pub struct TreeUsage {
86 pub processes: usize,
88 pub resident_bytes: u64,
90 pub unreadable: usize,
92}
93
94pub 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#[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
133pub 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#[derive(Clone, Copy, Debug)]
151pub struct BudgetLabels<'a> {
152 pub krate: &'a str,
154 pub version: &'a str,
155 pub scale_unit: &'a str,
157 pub scale: u64,
159 pub what: &'a str,
161 pub scope: &'a str,
163}
164
165impl IdleObservation {
166 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 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 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 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 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 #[cfg(target_pointer_width = "64")]
400 const THREADS_OFFSET: usize = 0x100;
401 #[cfg(target_pointer_width = "64")]
403 const MAX_QUERY_BYTES: usize = 256 * 1024 * 1024;
404
405 #[cfg(target_pointer_width = "64")]
410 fn wakeups_total() -> Option<u64> {
411 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 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 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 let thread = unsafe {
463 std::ptr::read_unaligned(
464 base.add(first + i * thread_size)
465 .cast::<SYSTEM_THREAD_INFORMATION>(),
466 )
467 };
468 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 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 #[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 #[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 while rx.lock().map(|r| r.recv().is_ok()).unwrap_or(false) {
568 let _ = done.send(());
569 }
570 })
571 })
572 .collect();
573 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 #[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 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}