1use std::io;
26use std::time::Duration;
27
28#[derive(Clone, Copy, Debug, Default, PartialEq)]
30pub struct ProcessUsage {
31 pub resident_bytes: u64,
33 pub footprint_bytes: u64,
35 pub cpu_seconds: f64,
37 pub wakeups_total: Option<u64>,
39 pub disk_ops_total: Option<u64>,
41 pub disk_read_bytes: Option<u64>,
43 pub disk_write_bytes: Option<u64>,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq)]
49pub struct UsageRates {
50 pub cpu_percent: f64,
52 pub wakeups_per_second: Option<f64>,
53 pub disk_ops_per_second: Option<f64>,
54 pub disk_read_bytes_per_second: Option<f64>,
55 pub disk_write_bytes_per_second: Option<f64>,
56}
57
58impl ProcessUsage {
59 pub fn rates_since(&self, earlier: &ProcessUsage, elapsed: Duration) -> UsageRates {
62 let secs = elapsed.as_secs_f64();
63 let rate = |now: Option<u64>, then: Option<u64>| match (now, then) {
64 (Some(now), Some(then)) if secs > 0.0 => Some(now.saturating_sub(then) as f64 / secs),
65 (Some(_), Some(_)) => Some(0.0),
66 _ => None,
67 };
68 UsageRates {
69 cpu_percent: if secs > 0.0 {
70 (self.cpu_seconds - earlier.cpu_seconds).max(0.0) / secs * 100.0
71 } else {
72 0.0
73 },
74 wakeups_per_second: rate(self.wakeups_total, earlier.wakeups_total),
75 disk_ops_per_second: rate(self.disk_ops_total, earlier.disk_ops_total),
76 disk_read_bytes_per_second: rate(self.disk_read_bytes, earlier.disk_read_bytes),
77 disk_write_bytes_per_second: rate(self.disk_write_bytes, earlier.disk_write_bytes),
78 }
79 }
80}
81
82pub fn process_usage() -> io::Result<ProcessUsage> {
84 imp::sample()
85}
86
87#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
91pub struct TreeUsage {
92 pub processes: usize,
94 pub resident_bytes: u64,
96 pub unreadable: usize,
98}
99
100pub fn tree_usage(root: &crate::AdoptedIdentity) -> io::Result<TreeUsage> {
106 if root.verify()? != crate::IdentityCheck::Same {
107 return Ok(TreeUsage::default());
108 }
109 let mut members = vec![root.clone()];
110 members.extend(root.descendants()?);
111 let table = crate::process_table()?;
112 let mut usage = TreeUsage::default();
113 for member in &members {
114 if member.verify()? != crate::IdentityCheck::Same {
115 continue;
116 }
117 usage.processes += 1;
118 match table
119 .iter()
120 .find(|entry| entry.pid == member.pid())
121 .and_then(|entry| entry.working_set_bytes)
122 {
123 Some(bytes) => usage.resident_bytes = usage.resident_bytes.saturating_add(bytes),
124 None => usage.unreadable += 1,
125 }
126 }
127 Ok(usage)
128}
129
130#[derive(Clone, Copy, Debug)]
132pub struct IdleObservation {
133 pub elapsed: Duration,
134 pub start: ProcessUsage,
135 pub end: ProcessUsage,
136 pub rates: UsageRates,
137}
138
139pub fn observe_idle(duration: Duration) -> io::Result<IdleObservation> {
142 let start = process_usage()?;
143 let began = std::time::Instant::now();
144 std::thread::sleep(duration);
145 let end = process_usage()?;
146 let elapsed = began.elapsed();
147 Ok(IdleObservation {
148 elapsed,
149 start,
150 end,
151 rates: end.rates_since(&start, elapsed),
152 })
153}
154
155#[derive(Clone, Debug, PartialEq)]
159#[non_exhaustive]
160pub struct ThreadSample {
161 pub thread_id: u64,
163 pub started: Option<u64>,
166 pub wakeups: Option<u64>,
169 pub cpu_seconds: Option<f64>,
171 pub name: Option<String>,
174 pub start_address: Option<u64>,
178}
179
180#[derive(Clone, Debug, PartialEq)]
182pub struct ProcessThreads {
183 pub usage: ProcessUsage,
184 pub threads: Vec<ThreadSample>,
187}
188
189impl ProcessThreads {
190 pub fn thread_wakeups_sum(&self) -> Option<u64> {
192 sum_wakeups(&self.threads)
193 }
194}
195
196fn sum_wakeups(threads: &[ThreadSample]) -> Option<u64> {
197 threads
198 .iter()
199 .try_fold(0u64, |total, thread| total.checked_add(thread.wakeups?))
200}
201
202pub fn process_usage_with_threads() -> io::Result<ProcessThreads> {
209 let (usage, mut threads) = imp::sample_threads()?;
210 imp::enrich(&mut threads);
211 Ok(ProcessThreads { usage, threads })
212}
213
214#[derive(Clone, Debug, PartialEq)]
216pub struct ThreadDelta {
217 pub thread_id: u64,
218 pub wakeups: Option<u64>,
220 pub cpu_seconds: Option<f64>,
222 pub name: Option<String>,
223 pub start_address: Option<u64>,
224 pub started_in_window: bool,
227}
228
229#[derive(Clone, Debug, PartialEq)]
231pub struct ThreadDeltas {
232 pub threads: Vec<ThreadDelta>,
235 pub exited: Vec<ThreadSample>,
238}
239
240pub fn thread_deltas(start: &[ThreadSample], end: &[ThreadSample]) -> ThreadDeltas {
243 use std::collections::HashMap;
244 let key = |thread: &ThreadSample| (thread.thread_id, thread.started);
245 let mut earlier: HashMap<(u64, Option<u64>), &ThreadSample> =
246 start.iter().map(|thread| (key(thread), thread)).collect();
247 let mut threads: Vec<ThreadDelta> = end
248 .iter()
249 .map(|now| {
250 let before = earlier.remove(&key(now));
251 ThreadDelta {
252 thread_id: now.thread_id,
253 wakeups: match before {
254 Some(then) => counter_delta(now.wakeups, then.wakeups),
255 None => now.wakeups,
256 },
257 cpu_seconds: match before {
258 Some(then) => match (now.cpu_seconds, then.cpu_seconds) {
259 (Some(now), Some(then)) => Some((now - then).max(0.0)),
260 _ => None,
261 },
262 None => now.cpu_seconds,
263 },
264 name: now
265 .name
266 .clone()
267 .or_else(|| before.and_then(|then| then.name.clone())),
268 start_address: now
269 .start_address
270 .or_else(|| before.and_then(|then| then.start_address)),
271 started_in_window: before.is_none(),
272 }
273 })
274 .collect();
275 threads.sort_by(|a, b| {
276 b.wakeups
277 .cmp(&a.wakeups)
278 .then(a.thread_id.cmp(&b.thread_id))
279 });
280 let mut exited: Vec<ThreadSample> = earlier.into_values().cloned().collect();
281 exited.sort_by_key(|thread| thread.thread_id);
282 ThreadDeltas { threads, exited }
283}
284
285fn counter_delta(now: Option<u64>, then: Option<u64>) -> Option<u64> {
286 Some(now?.saturating_sub(then?))
287}
288
289impl ThreadDeltas {
290 pub fn total_wakeups(&self) -> Option<u64> {
294 self.threads
295 .iter()
296 .try_fold(0u64, |total, thread| total.checked_add(thread.wakeups?))
297 }
298
299 pub fn exited_wakeups_at_start(&self) -> Option<u64> {
301 sum_wakeups(&self.exited)
302 }
303
304 pub fn top(&self, n: usize) -> &[ThreadDelta] {
306 &self.threads[..n.min(self.threads.len())]
307 }
308
309 pub fn result_lines(&self, elapsed: Duration, top: usize) -> String {
315 let secs = elapsed.as_secs_f64();
316 let count = |v: Option<u64>| v.map_or_else(|| "unavailable".to_string(), |v| v.to_string());
317 let mut out = format!(
318 "RESULT thread_wakeups_summary threads={} born={} exited={} listed_wakeups={} exited_wakeups_at_start={} window_seconds={:.1}\n",
319 self.threads.len(),
320 self.threads.iter().filter(|t| t.started_in_window).count(),
321 self.exited.len(),
322 count(self.total_wakeups()),
323 count(self.exited_wakeups_at_start()),
324 secs,
325 );
326 let listed = self
327 .threads
328 .iter()
329 .filter(|t| t.wakeups != Some(0))
330 .take(top);
331 for (index, thread) in listed.enumerate() {
332 let per_second = match thread.wakeups {
333 Some(w) if secs > 0.0 => format!("{:.3}", w as f64 / secs),
334 Some(_) => "0.000".to_string(),
335 None => "unavailable".to_string(),
336 };
337 out.push_str(&format!(
338 "RESULT thread_wakeups rank={} tid={} wakeups={} per_second={per_second} cpu_ms={} born={} start={} name={}\n",
339 index + 1,
340 thread.thread_id,
341 count(thread.wakeups),
342 thread
343 .cpu_seconds
344 .map_or_else(|| "unavailable".to_string(), |s| format!("{:.1}", s * 1e3)),
345 u8::from(thread.started_in_window),
346 thread
347 .start_address
348 .map_or_else(|| "unavailable".to_string(), |a| format!("0x{a:x}")),
349 thread
350 .name
351 .as_ref()
352 .map_or_else(|| "unavailable".to_string(), |n| format!("{n:?}")),
353 ));
354 }
355 out
356 }
357}
358
359#[derive(Clone, Debug)]
361pub struct IdleThreadObservation {
362 pub observation: IdleObservation,
364 pub threads: ThreadDeltas,
365}
366
367pub fn observe_idle_with_threads(duration: Duration) -> io::Result<IdleThreadObservation> {
371 let (start, mut start_threads) = imp::sample_threads()?;
372 let began = std::time::Instant::now();
373 imp::enrich(&mut start_threads);
374 std::thread::sleep(duration.saturating_sub(began.elapsed()));
375 let (end, mut end_threads) = imp::sample_threads()?;
376 let elapsed = began.elapsed();
377 imp::enrich(&mut end_threads);
378 Ok(IdleThreadObservation {
379 observation: IdleObservation {
380 elapsed,
381 start,
382 end,
383 rates: end.rates_since(&start, elapsed),
384 },
385 threads: thread_deltas(&start_threads, &end_threads),
386 })
387}
388
389#[derive(Clone, Copy, Debug)]
391pub struct BudgetLabels<'a> {
392 pub krate: &'a str,
394 pub version: &'a str,
395 pub scale_unit: &'a str,
397 pub scale: u64,
399 pub what: &'a str,
401 pub scope: &'a str,
403}
404
405impl IdleObservation {
406 pub fn result_lines(&self, warm: &ProcessUsage, labels: &BudgetLabels<'_>) -> String {
421 let BudgetLabels {
422 krate,
423 version,
424 scale_unit,
425 scale,
426 what,
427 scope,
428 } = *labels;
429 let mut out = String::new();
430 let mut line = |text: String| {
431 out.push_str("RESULT ");
432 out.push_str(&text);
433 out.push('\n');
434 };
435 let resident_max = warm.resident_bytes.max(self.end.resident_bytes);
436 line(format!("warm_resident_bytes={} bytes", warm.resident_bytes));
437 line(format!(
438 "idle_resident_bytes={} bytes",
439 self.end.resident_bytes
440 ));
441 if resident_max > 0 {
442 line(format!("resident_memory_bytes={resident_max} bytes"));
443 } else {
444 line("resident_memory_bytes=unavailable host reported a zero resident set".to_string());
445 }
446 line(format!(
447 "footprint_bytes={} bytes",
448 warm.footprint_bytes.max(self.end.footprint_bytes)
449 ));
450 line(format!(
451 "idle_window_seconds={:.1} seconds",
452 self.elapsed.as_secs_f64()
453 ));
454 line(format!(
455 "idle_cpu_percent={:.3} percent",
456 self.rates.cpu_percent
457 ));
458 let opt = |name: &str,
459 value: Option<f64>,
460 unit: &str,
461 reason: &str,
462 line: &mut dyn FnMut(String)| match value {
463 Some(v) => line(format!("{name}={v:.3} {unit}")),
464 None => line(format!("{name}=unavailable {reason}")),
465 };
466 opt(
467 "wakeups_per_second",
468 self.rates.wakeups_per_second,
469 "per_second",
470 "the host exposes no process wake-up counter",
471 &mut line,
472 );
473 opt(
474 "disk_ops_per_second",
475 self.rates.disk_ops_per_second,
476 "per_second",
477 "the host exposes no process disk-operation counter",
478 &mut line,
479 );
480 opt(
481 "disk_read_bytes_per_second",
482 self.rates.disk_read_bytes_per_second,
483 "bytes_per_second",
484 "the host exposes no process disk-byte counter",
485 &mut line,
486 );
487 opt(
488 "disk_write_bytes_per_second",
489 self.rates.disk_write_bytes_per_second,
490 "bytes_per_second",
491 "the host exposes no process disk-byte counter",
492 &mut line,
493 );
494 line(format!("scale={scale} {what}"));
495 line(format!("scope={scope}"));
496 let num =
497 |v: Option<f64>| v.map_or_else(|| "unavailable".to_string(), |v| format!("{v:.3}"));
498 let resident = if resident_max > 0 {
499 resident_max.to_string()
500 } else {
501 "unavailable".to_string()
502 };
503 line(format!(
504 "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}",
505 self.rates.cpu_percent,
506 num(self.rates.wakeups_per_second),
507 num(self.rates.disk_ops_per_second),
508 ));
509 out
510 }
511}
512
513#[cfg(target_os = "macos")]
514mod imp {
515 use super::{ProcessUsage, ThreadSample};
516 use std::io;
517
518 extern "C" {
519 fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut u8) -> i32;
520 }
521
522 const W_INTERRUPT_WKUPS: usize = 2 + 3;
525 const W_RESIDENT: usize = 2 + 6;
526 const W_FOOTPRINT: usize = 2 + 7;
527 const W_DISK_READ: usize = 2 + 16;
528 const W_DISK_WRITE: usize = 2 + 17;
529
530 pub fn sample() -> io::Result<ProcessUsage> {
531 let mut words = [0u64; 64];
532 let rc = unsafe { proc_pid_rusage(libc::getpid(), 2, words.as_mut_ptr().cast()) };
534 if rc != 0 {
535 return Err(io::Error::last_os_error());
536 }
537 let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
538 if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
539 return Err(io::Error::last_os_error());
540 }
541 let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
542 Ok(ProcessUsage {
543 resident_bytes: words[W_RESIDENT],
544 footprint_bytes: words[W_FOOTPRINT],
545 cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
546 wakeups_total: Some(words[W_INTERRUPT_WKUPS]),
547 disk_ops_total: None,
548 disk_read_bytes: Some(words[W_DISK_READ]),
549 disk_write_bytes: Some(words[W_DISK_WRITE]),
550 })
551 }
552
553 const PROC_PIDLISTTHREADS: i32 = 6;
556 const MAX_THREADS: usize = 1 << 16;
558
559 fn threads() -> io::Result<Vec<ThreadSample>> {
563 let pid = unsafe { libc::getpid() };
564 let mut capacity = 64usize;
565 let handles = loop {
566 let mut handles = vec![0u64; capacity];
567 let bytes = i32::try_from(capacity * std::mem::size_of::<u64>())
568 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "thread list too large"))?;
569 let written = unsafe {
571 libc::proc_pidinfo(
572 pid,
573 PROC_PIDLISTTHREADS,
574 0,
575 handles.as_mut_ptr().cast(),
576 bytes,
577 )
578 };
579 if written <= 0 {
580 return Err(io::Error::last_os_error());
581 }
582 let count = written as usize / std::mem::size_of::<u64>();
583 if count < capacity {
585 handles.truncate(count);
586 break handles;
587 }
588 capacity *= 2;
589 if capacity > MAX_THREADS {
590 return Err(io::Error::new(
591 io::ErrorKind::InvalidData,
592 "too many threads to list",
593 ));
594 }
595 };
596 let size = std::mem::size_of::<libc::proc_threadinfo>() as i32;
597 let mut threads = Vec::with_capacity(handles.len());
598 for handle in handles {
599 let mut info: libc::proc_threadinfo = unsafe { std::mem::zeroed() };
600 let read = unsafe {
602 libc::proc_pidinfo(
603 pid,
604 libc::PROC_PIDTHREADINFO,
605 handle,
606 (&mut info as *mut libc::proc_threadinfo).cast(),
607 size,
608 )
609 };
610 if read != size {
611 continue;
612 }
613 let bytes: Vec<u8> = info
614 .pth_name
615 .iter()
616 .map(|&c| c as u8)
617 .take_while(|&c| c != 0)
618 .collect();
619 let name = String::from_utf8_lossy(&bytes).into_owned();
620 threads.push(ThreadSample {
621 thread_id: handle,
622 started: None,
623 wakeups: None,
624 cpu_seconds: None,
625 name: (!name.is_empty()).then_some(name),
626 start_address: None,
627 });
628 }
629 Ok(threads)
630 }
631
632 pub fn sample_threads() -> io::Result<(ProcessUsage, Vec<ThreadSample>)> {
635 let usage = sample()?;
636 Ok((usage, threads()?))
637 }
638
639 pub fn enrich(_threads: &mut [ThreadSample]) {}
641}
642
643#[cfg(all(
644 unix,
645 not(target_os = "macos"),
646 not(any(target_os = "linux", target_os = "android"))
647))]
648mod imp {
649 use super::{ProcessUsage, ThreadSample};
650 use std::io;
651
652 pub fn sample() -> io::Result<ProcessUsage> {
655 Err(io::Error::new(
656 io::ErrorKind::Unsupported,
657 "process usage sampling is implemented for macOS, Linux, Android and Windows only",
658 ))
659 }
660
661 pub fn sample_threads() -> io::Result<(ProcessUsage, Vec<ThreadSample>)> {
662 sample().map(|usage| (usage, Vec::new()))
663 }
664
665 pub fn enrich(_threads: &mut [ThreadSample]) {}
666}
667
668#[cfg(any(target_os = "linux", target_os = "android"))]
669mod imp {
670 use super::{ProcessUsage, ThreadSample};
671 use std::io;
672
673 pub fn sample() -> io::Result<ProcessUsage> {
674 let statm = std::fs::read_to_string("/proc/self/statm")?;
675 let pages: u64 = statm
676 .split_whitespace()
677 .nth(1)
678 .and_then(|v| v.parse().ok())
679 .ok_or_else(|| {
680 io::Error::new(io::ErrorKind::InvalidData, "unreadable /proc/self/statm")
681 })?;
682 let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(1))
683 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "page size out of range"))?;
684 let overflow = || io::Error::new(io::ErrorKind::InvalidData, "memory size overflows u64");
685 let resident = pages.checked_mul(page).ok_or_else(overflow)?;
686 let blocks = |count: libc::c_long| u64::try_from(count.max(0)).unwrap_or(0);
688 let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
689 if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) } != 0 {
690 return Err(io::Error::last_os_error());
691 }
692 let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 / 1e6;
693 Ok(ProcessUsage {
694 resident_bytes: resident,
695 footprint_bytes: resident,
696 cpu_seconds: secs(ru.ru_utime) + secs(ru.ru_stime),
697 wakeups_total: None,
698 disk_ops_total: blocks(ru.ru_inblock).checked_add(blocks(ru.ru_oublock)),
699 disk_read_bytes: None,
700 disk_write_bytes: None,
701 })
702 }
703
704 fn threads() -> io::Result<Vec<ThreadSample>> {
708 let mut threads = Vec::new();
709 for entry in std::fs::read_dir("/proc/self/task")? {
710 let Ok(entry) = entry else { continue };
711 let Some(tid) = entry
712 .file_name()
713 .to_str()
714 .and_then(|n| n.parse::<u64>().ok())
715 else {
716 continue;
717 };
718 let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) else {
719 continue;
720 };
721 let name = comm.trim_end().to_string();
722 threads.push(ThreadSample {
723 thread_id: tid,
724 started: None,
725 wakeups: None,
726 cpu_seconds: None,
727 name: (!name.is_empty()).then_some(name),
728 start_address: None,
729 });
730 }
731 Ok(threads)
732 }
733
734 pub fn sample_threads() -> io::Result<(ProcessUsage, Vec<ThreadSample>)> {
735 let usage = sample()?;
736 Ok((usage, threads()?))
737 }
738
739 pub fn enrich(_threads: &mut [ThreadSample]) {}
741}
742
743#[cfg(windows)]
744mod imp {
745 use super::{ProcessUsage, ThreadSample};
746 use std::io;
747 use windows::Wdk::System::SystemInformation::{
748 NtQuerySystemInformation, SystemProcessInformation,
749 };
750 use windows::Win32::Foundation::{
751 CloseHandle, LocalFree, FILETIME, HANDLE, HLOCAL, STATUS_INFO_LENGTH_MISMATCH,
752 };
753 use windows::Win32::System::ProcessStatus::{
754 GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX,
755 };
756 use windows::Win32::System::Threading::{
757 GetCurrentProcess, GetCurrentProcessId, GetProcessIoCounters, GetProcessTimes,
758 GetThreadDescription, GetThreadTimes, OpenThread, IO_COUNTERS, THREAD_QUERY_INFORMATION,
759 THREAD_QUERY_LIMITED_INFORMATION,
760 };
761 use windows::Win32::System::WindowsProgramming::{
762 SYSTEM_PROCESS_INFORMATION, SYSTEM_THREAD_INFORMATION,
763 };
764
765 fn os(error: windows::core::Error) -> io::Error {
766 io::Error::from_raw_os_error(error.code().0 & 0xFFFF)
767 }
768
769 fn ticks(t: FILETIME) -> u64 {
770 ((t.dwHighDateTime as u64) << 32) | t.dwLowDateTime as u64
771 }
772
773 #[cfg(target_pointer_width = "64")]
777 const THREADS_OFFSET: usize = 0x100;
778 #[cfg(target_pointer_width = "64")]
780 const MAX_QUERY_BYTES: usize = 256 * 1024 * 1024;
781
782 #[cfg(target_pointer_width = "64")]
789 fn thread_table() -> Option<Vec<ThreadSample>> {
790 let mut words: Vec<u64> = vec![0; (1 << 20) / 8];
792 let mut length: u32 = 0;
793 let mut attempts = 0;
794 loop {
795 let capacity = words.len() * 8;
796 let status = unsafe {
797 NtQuerySystemInformation(
798 SystemProcessInformation,
799 words.as_mut_ptr().cast(),
800 u32::try_from(capacity).ok()?,
801 &mut length,
802 )
803 };
804 if status.0 >= 0 {
805 break;
806 }
807 attempts += 1;
808 if status != STATUS_INFO_LENGTH_MISMATCH || attempts > 8 {
809 return None;
810 }
811 let want = (length as usize).checked_add(256 * 1024)?;
813 if want <= capacity || want > MAX_QUERY_BYTES {
814 return None;
815 }
816 words = vec![0; want.div_ceil(8)];
817 }
818 let valid = (length as usize).min(words.len() * 8);
819 let base = words.as_ptr().cast::<u8>();
820 let pid = unsafe { GetCurrentProcessId() } as usize;
821 let thread_size = std::mem::size_of::<SYSTEM_THREAD_INFORMATION>();
822 let mut offset = 0usize;
823 loop {
824 if offset.checked_add(THREADS_OFFSET)? > valid {
825 return None;
826 }
827 let entry = unsafe {
829 std::ptr::read_unaligned(base.add(offset).cast::<SYSTEM_PROCESS_INFORMATION>())
830 };
831 if entry.UniqueProcessId.0 as usize == pid {
832 let count = entry.NumberOfThreads as usize;
833 let first = offset + THREADS_OFFSET;
834 let end = first.checked_add(count.checked_mul(thread_size)?)?;
835 if end > valid {
836 return None;
837 }
838 let mut threads = Vec::with_capacity(count);
839 for i in 0..count {
840 let thread = unsafe {
842 std::ptr::read_unaligned(
843 base.add(first + i * thread_size)
844 .cast::<SYSTEM_THREAD_INFORMATION>(),
845 )
846 };
847 if thread.ClientId.UniqueProcess.0 as usize != pid {
849 return None;
850 }
851 let [kernel, user, created] = thread.Reserved1;
852 threads.push(ThreadSample {
853 thread_id: thread.ClientId.UniqueThread.0 as usize as u64,
854 started: u64::try_from(created).ok(),
855 wakeups: Some(u64::from(thread.Reserved3)),
856 cpu_seconds: Some(
858 (kernel.max(0) as u64).saturating_add(user.max(0) as u64) as f64 / 1e7,
859 ),
860 name: None,
861 start_address: None,
862 });
863 }
864 return Some(threads);
865 }
866 let next = entry.NextEntryOffset as usize;
867 if next == 0 {
868 return None;
869 }
870 offset = offset.checked_add(next)?;
871 }
872 }
873
874 #[cfg(not(target_pointer_width = "64"))]
875 fn thread_table() -> Option<Vec<ThreadSample>> {
876 None
877 }
878
879 struct ThreadHandle(HANDLE);
881
882 impl Drop for ThreadHandle {
883 fn drop(&mut self) {
884 let _ = unsafe { CloseHandle(self.0) };
886 }
887 }
888
889 fn open_thread(id: u32) -> Option<ThreadHandle> {
894 let full = THREAD_QUERY_INFORMATION | THREAD_QUERY_LIMITED_INFORMATION;
895 let opened = unsafe { OpenThread(full, false, id) }
897 .or_else(|_| unsafe { OpenThread(THREAD_QUERY_LIMITED_INFORMATION, false, id) });
898 opened.ok().map(ThreadHandle)
899 }
900
901 fn thread_created(thread: HANDLE) -> Option<u64> {
903 let (mut created, mut exited, mut kernel, mut user) = (
904 FILETIME::default(),
905 FILETIME::default(),
906 FILETIME::default(),
907 FILETIME::default(),
908 );
909 let read =
911 unsafe { GetThreadTimes(thread, &mut created, &mut exited, &mut kernel, &mut user) };
912 read.ok()?;
913 Some(ticks(created))
914 }
915
916 fn thread_name(thread: HANDLE) -> Option<String> {
918 let description = unsafe { GetThreadDescription(thread) }.ok()?;
920 if description.is_null() {
921 return None;
922 }
923 let name = unsafe { description.to_string() }.ok();
925 unsafe { LocalFree(Some(HLOCAL(description.0.cast()))) };
927 name.filter(|name| !name.is_empty())
928 }
929
930 #[cfg(target_pointer_width = "64")]
934 const THREAD_QUERY_SET_WIN32_START_ADDRESS: u32 = 9;
935
936 #[cfg(target_pointer_width = "64")]
939 #[link(name = "ntdll")]
940 extern "system" {
941 fn NtQueryInformationThread(
942 thread: HANDLE,
943 class: u32,
944 information: *mut std::ffi::c_void,
945 length: u32,
946 returned: *mut u32,
947 ) -> i32;
948 }
949
950 #[cfg(target_pointer_width = "64")]
951 fn win32_start_address(thread: HANDLE) -> Option<u64> {
952 let mut address: usize = 0;
953 let status = unsafe {
955 NtQueryInformationThread(
956 thread,
957 THREAD_QUERY_SET_WIN32_START_ADDRESS,
958 (&mut address as *mut usize).cast(),
959 std::mem::size_of::<usize>() as u32,
960 std::ptr::null_mut(),
961 )
962 };
963 (status >= 0 && address != 0).then_some(address as u64)
964 }
965
966 #[cfg(not(target_pointer_width = "64"))]
967 fn win32_start_address(_thread: HANDLE) -> Option<u64> {
968 None
969 }
970
971 pub fn sample_threads() -> io::Result<(ProcessUsage, Vec<ThreadSample>)> {
974 let threads = thread_table().ok_or_else(|| io::Error::other("thread table unavailable"))?;
975 let usage = sample_with(super::sum_wakeups(&threads))?;
976 Ok((usage, threads))
977 }
978
979 pub fn enrich(threads: &mut [ThreadSample]) {
983 for thread in threads.iter_mut() {
984 let Some(handle) = u32::try_from(thread.thread_id).ok().and_then(open_thread) else {
985 continue;
986 };
987 if thread.started.is_some() && thread_created(handle.0) != thread.started {
988 continue;
989 }
990 thread.name = thread_name(handle.0);
991 thread.start_address = win32_start_address(handle.0);
992 }
993 }
994
995 pub fn sample() -> io::Result<ProcessUsage> {
996 let table = thread_table();
997 sample_with(table.as_deref().and_then(super::sum_wakeups))
998 }
999
1000 fn sample_with(wakeups_total: Option<u64>) -> io::Result<ProcessUsage> {
1001 unsafe {
1002 let process = GetCurrentProcess();
1003 let mut mem = PROCESS_MEMORY_COUNTERS_EX::default();
1004 GetProcessMemoryInfo(
1005 process,
1006 (&mut mem as *mut PROCESS_MEMORY_COUNTERS_EX).cast::<PROCESS_MEMORY_COUNTERS>(),
1007 std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
1008 )
1009 .map_err(os)?;
1010 let (mut created, mut exited, mut kernel, mut user) = (
1011 FILETIME::default(),
1012 FILETIME::default(),
1013 FILETIME::default(),
1014 FILETIME::default(),
1015 );
1016 GetProcessTimes(process, &mut created, &mut exited, &mut kernel, &mut user)
1017 .map_err(os)?;
1018 let mut io_counters = IO_COUNTERS::default();
1019 GetProcessIoCounters(process, &mut io_counters).map_err(os)?;
1020 Ok(ProcessUsage {
1021 resident_bytes: mem.WorkingSetSize as u64,
1022 footprint_bytes: mem.PrivateUsage as u64,
1023 cpu_seconds: (ticks(kernel) + ticks(user)) as f64 / 1e7,
1025 wakeups_total,
1026 disk_ops_total: Some(
1027 io_counters.ReadOperationCount + io_counters.WriteOperationCount,
1028 ),
1029 disk_read_bytes: Some(io_counters.ReadTransferCount),
1030 disk_write_bytes: Some(io_counters.WriteTransferCount),
1031 })
1032 }
1033 }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038 use super::*;
1039
1040 #[cfg(any(
1042 target_os = "macos",
1043 target_os = "linux",
1044 target_os = "android",
1045 windows
1046 ))]
1047 #[test]
1048 fn sample_reports_resident_memory_and_cpu() {
1049 let first = process_usage().unwrap();
1050 assert!(first.resident_bytes > 0);
1051 assert!(first.footprint_bytes > 0);
1052 let mut sink = 0u64;
1053 for i in 0..20_000_000u64 {
1054 sink = sink.wrapping_add(i * 31);
1055 }
1056 std::hint::black_box(sink);
1057 let second = process_usage().unwrap();
1058 assert!(second.cpu_seconds >= first.cpu_seconds);
1059 let rates = second.rates_since(&first, Duration::from_secs(1));
1060 assert!(rates.cpu_percent >= 0.0);
1061 }
1062
1063 #[cfg(all(windows, target_pointer_width = "64"))]
1066 #[test]
1067 fn windows_wakeups_count_thread_context_switches() {
1068 use std::sync::{mpsc, Arc, Mutex};
1069 let (tx, rx) = mpsc::channel::<()>();
1070 let rx = Arc::new(Mutex::new(rx));
1071 let (done_tx, done_rx) = mpsc::channel::<()>();
1072 let workers: Vec<_> = (0..3)
1073 .map(|_| {
1074 let rx = rx.clone();
1075 let done = done_tx.clone();
1076 std::thread::spawn(move || {
1077 while rx.lock().map(|r| r.recv().is_ok()).unwrap_or(false) {
1079 let _ = done.send(());
1080 }
1081 })
1082 })
1083 .collect();
1084 let mut ok = false;
1087 for _ in 0..5 {
1088 let first = process_usage()
1089 .unwrap()
1090 .wakeups_total
1091 .expect("windows reports wake-ups");
1092 for _ in 0..5 {
1093 std::thread::sleep(Duration::from_millis(10));
1094 tx.send(()).unwrap();
1095 done_rx.recv().unwrap();
1096 }
1097 let second = process_usage()
1098 .unwrap()
1099 .wakeups_total
1100 .expect("windows reports wake-ups");
1101 if second >= first {
1102 ok = true;
1103 break;
1104 }
1105 }
1106 drop(tx);
1107 for worker in workers {
1108 let _ = worker.join();
1109 }
1110 assert!(ok, "wake-up total decreased on every attempt");
1111 }
1112
1113 #[cfg(all(
1115 unix,
1116 not(target_os = "macos"),
1117 not(any(target_os = "linux", target_os = "android"))
1118 ))]
1119 #[test]
1120 fn unsupported_unix_targets_report_unsupported_not_a_sample() {
1121 let error = process_usage().expect_err("no sampler exists for this target");
1122 assert_eq!(error.kind(), io::ErrorKind::Unsupported);
1123 let error = observe_idle(Duration::from_millis(1)).expect_err("idle needs sampling");
1124 assert_eq!(error.kind(), io::ErrorKind::Unsupported);
1125 }
1126
1127 #[cfg(any(
1128 target_os = "macos",
1129 target_os = "linux",
1130 target_os = "android",
1131 windows
1132 ))]
1133 #[test]
1134 fn tree_usage_counts_the_remembered_root_and_refuses_a_replaced_one() {
1135 let pid = std::num::NonZeroU32::new(std::process::id()).unwrap();
1136 let root = crate::AdoptedIdentity::capture(pid)
1137 .unwrap()
1138 .expect("this process has an identity");
1139 let usage = tree_usage(&root).unwrap();
1140 assert!(usage.processes >= 1, "{usage:?}");
1141 assert!(usage.resident_bytes > 0, "{usage:?}");
1142 let replaced = crate::AdoptedIdentity::from_recorded(
1144 pid,
1145 crate::StartToken {
1146 major: root.start_token().major.wrapping_add(1),
1147 minor: root.start_token().minor,
1148 },
1149 None,
1150 );
1151 assert_eq!(tree_usage(&replaced).unwrap(), TreeUsage::default());
1152 }
1153
1154 #[test]
1155 fn rates_are_none_without_counters_and_zero_for_no_time() {
1156 let a = ProcessUsage {
1157 wakeups_total: None,
1158 ..Default::default()
1159 };
1160 let b = ProcessUsage {
1161 wakeups_total: Some(10),
1162 ..Default::default()
1163 };
1164 assert_eq!(
1165 b.rates_since(&a, Duration::from_secs(1)).wakeups_per_second,
1166 None
1167 );
1168 let c = ProcessUsage {
1169 wakeups_total: Some(30),
1170 ..Default::default()
1171 };
1172 assert_eq!(
1173 c.rates_since(&b, Duration::from_secs(2)).wakeups_per_second,
1174 Some(10.0)
1175 );
1176 assert_eq!(c.rates_since(&b, Duration::ZERO).cpu_percent, 0.0);
1177 }
1178
1179 fn thread(id: u64, started: u64, wakeups: Option<u64>) -> ThreadSample {
1180 ThreadSample {
1181 thread_id: id,
1182 started: Some(started),
1183 wakeups,
1184 cpu_seconds: wakeups.map(|w| w as f64 / 1000.0),
1185 name: None,
1186 start_address: None,
1187 }
1188 }
1189
1190 #[test]
1191 fn deltas_match_by_identity_sort_by_wakeups_and_list_exited_threads() {
1192 let start = [
1193 thread(1, 100, Some(10)),
1194 thread(2, 200, Some(50)),
1195 thread(3, 300, Some(7)),
1196 ];
1197 let end = [
1198 thread(1, 100, Some(14)),
1199 thread(2, 200, Some(51)),
1200 thread(4, 400, Some(30)),
1201 ];
1202 let deltas = thread_deltas(&start, &end);
1203 let listed: Vec<(u64, Option<u64>, bool)> = deltas
1204 .threads
1205 .iter()
1206 .map(|d| (d.thread_id, d.wakeups, d.started_in_window))
1207 .collect();
1208 assert_eq!(
1210 listed,
1211 vec![
1212 (4, Some(30), true),
1213 (1, Some(4), false),
1214 (2, Some(1), false),
1215 ]
1216 );
1217 assert_eq!(deltas.exited.len(), 1);
1218 assert_eq!(deltas.exited[0].thread_id, 3);
1219 assert_eq!(deltas.total_wakeups(), Some(35));
1220 assert_eq!(deltas.exited_wakeups_at_start(), Some(7));
1221 assert_eq!(
1223 deltas.total_wakeups().unwrap() - deltas.exited_wakeups_at_start().unwrap(),
1224 sum_wakeups(&end).unwrap() - sum_wakeups(&start).unwrap()
1225 );
1226 let cpu = deltas.threads.iter().find(|d| d.thread_id == 1).unwrap();
1227 assert!((cpu.cpu_seconds.unwrap() - 0.004).abs() < 1e-9);
1228 assert_eq!(deltas.top(2).len(), 2);
1229 assert_eq!(deltas.top(99).len(), 3);
1230 assert_eq!(deltas.top(0).len(), 0);
1231 }
1232
1233 #[test]
1234 fn a_reused_thread_id_with_a_new_creation_time_is_a_new_thread() {
1235 let start = [thread(9, 100, Some(500))];
1236 let end = [thread(9, 999, Some(3))];
1237 let deltas = thread_deltas(&start, &end);
1238 assert_eq!(deltas.threads.len(), 1);
1239 assert_eq!(deltas.threads[0].wakeups, Some(3));
1240 assert!(deltas.threads[0].started_in_window);
1241 assert_eq!(deltas.exited.len(), 1);
1242 assert_eq!(deltas.exited_wakeups_at_start(), Some(500));
1243 }
1244
1245 #[test]
1246 fn a_counter_that_went_backwards_counts_as_zero_not_a_wrapped_value() {
1247 let deltas = thread_deltas(&[thread(1, 1, Some(10))], &[thread(1, 1, Some(4))]);
1248 assert_eq!(deltas.threads[0].wakeups, Some(0));
1249 assert_eq!(deltas.total_wakeups(), Some(0));
1250 }
1251
1252 #[test]
1253 fn unknown_counters_stay_unknown_and_sort_last() {
1254 let start = [thread(1, 1, None), thread(2, 2, Some(1))];
1255 let end = [thread(1, 1, None), thread(2, 2, Some(5))];
1256 let deltas = thread_deltas(&start, &end);
1257 assert_eq!(deltas.threads[0].thread_id, 2);
1258 assert_eq!(deltas.threads[0].wakeups, Some(4));
1259 assert_eq!(deltas.threads[1].wakeups, None);
1260 assert_eq!(deltas.threads[1].cpu_seconds, None);
1261 assert_eq!(deltas.total_wakeups(), None);
1262 let none = ThreadDeltas {
1263 threads: Vec::new(),
1264 exited: Vec::new(),
1265 };
1266 assert_eq!(none.total_wakeups(), Some(0));
1267 assert_eq!(
1268 sum_wakeups(&[thread(1, 1, Some(2)), thread(2, 2, None)]),
1269 None
1270 );
1271 assert_eq!(
1272 sum_wakeups(&[thread(1, 1, Some(2)), thread(2, 2, Some(3))]),
1273 Some(5)
1274 );
1275 assert_eq!(
1276 sum_wakeups(&[thread(1, 1, Some(u64::MAX)), thread(2, 2, Some(1))]),
1277 None
1278 );
1279 }
1280
1281 #[test]
1282 fn a_name_or_address_read_only_at_one_end_is_kept() {
1283 let mut first = thread(1, 1, Some(1));
1284 first.name = Some("worker".to_string());
1285 let mut last = thread(1, 1, Some(2));
1286 last.start_address = Some(0x1000);
1287 let deltas = thread_deltas(&[first], &[last]);
1288 assert_eq!(deltas.threads[0].name.as_deref(), Some("worker"));
1289 assert_eq!(deltas.threads[0].start_address, Some(0x1000));
1290 }
1291
1292 #[test]
1293 fn thread_result_lines_are_not_budget_lines_and_list_only_active_threads() {
1294 let mut busy = thread(7, 1, Some(100));
1295 busy.name = Some("tokio-runtime-worker".to_string());
1296 busy.start_address = Some(0x7ff6_1234);
1297 let deltas = thread_deltas(
1298 &[thread(7, 1, Some(0)), thread(8, 2, Some(5))],
1299 &[busy, thread(8, 2, Some(5))],
1300 );
1301 let text = deltas.result_lines(Duration::from_secs(50), 5);
1302 let lines: Vec<&str> = text.lines().collect();
1303 assert_eq!(lines.len(), 2, "{text}");
1304 assert!(lines
1305 .iter()
1306 .all(|line| line.starts_with("RESULT thread_wakeups") && !line.contains("budget")));
1307 assert_eq!(
1308 lines[0],
1309 "RESULT thread_wakeups_summary threads=2 born=0 exited=0 listed_wakeups=100 exited_wakeups_at_start=0 window_seconds=50.0"
1310 );
1311 assert!(lines[1]
1312 .starts_with("RESULT thread_wakeups rank=1 tid=7 wakeups=100 per_second=2.000 "));
1313 assert!(lines[1].ends_with(" born=0 start=0x7ff61234 name=\"tokio-runtime-worker\""));
1314 let zero = deltas.result_lines(Duration::ZERO, 5);
1316 assert!(zero.contains("per_second=0.000"), "{zero}");
1317 assert_eq!(
1318 deltas
1319 .result_lines(Duration::from_secs(1), 0)
1320 .lines()
1321 .count(),
1322 1
1323 );
1324 }
1325
1326 #[cfg(any(
1327 target_os = "macos",
1328 target_os = "linux",
1329 target_os = "android",
1330 windows
1331 ))]
1332 #[test]
1333 fn thread_snapshot_lists_unique_live_threads() {
1334 let snapshot = process_usage_with_threads().unwrap();
1335 assert!(!snapshot.threads.is_empty());
1336 let mut ids: Vec<u64> = snapshot.threads.iter().map(|t| t.thread_id).collect();
1337 let listed = ids.len();
1338 ids.sort_unstable();
1339 ids.dedup();
1340 assert_eq!(ids.len(), listed, "a thread was listed twice");
1341 }
1342
1343 #[cfg(all(windows, target_pointer_width = "64"))]
1346 #[test]
1347 fn windows_threads_sum_to_the_total_and_attribute_wakeups_to_the_woken_thread() {
1348 use std::sync::mpsc;
1349 let (id_tx, id_rx) = mpsc::channel::<u32>();
1350 let (wake_tx, wake_rx) = mpsc::channel::<()>();
1351 let (ack_tx, ack_rx) = mpsc::channel::<()>();
1352 let probe = std::thread::Builder::new()
1353 .name("rk-usage-probe".to_string())
1354 .spawn(move || {
1355 id_tx
1357 .send(unsafe { windows::Win32::System::Threading::GetCurrentThreadId() })
1358 .unwrap();
1359 while wake_rx.recv().is_ok() {
1360 let _ = ack_tx.send(());
1361 }
1362 })
1363 .unwrap();
1364 let id = u64::from(id_rx.recv().unwrap());
1365 let first = process_usage_with_threads().unwrap();
1366 for _ in 0..5 {
1367 std::thread::sleep(Duration::from_millis(10));
1368 wake_tx.send(()).unwrap();
1369 ack_rx.recv().unwrap();
1370 }
1371 let second = process_usage_with_threads().unwrap();
1372 drop(wake_tx);
1373 probe.join().unwrap();
1374
1375 for snapshot in [&first, &second] {
1376 assert!(snapshot.usage.wakeups_total.is_some());
1377 assert_eq!(snapshot.usage.wakeups_total, snapshot.thread_wakeups_sum());
1378 }
1379 let probe_now = second
1380 .threads
1381 .iter()
1382 .find(|t| t.thread_id == id)
1383 .expect("the probe thread is listed");
1384 assert_eq!(probe_now.name.as_deref(), Some("rk-usage-probe"));
1385 assert!(probe_now.start_address.is_some());
1386 assert!(probe_now.started.is_some());
1387 let deltas = thread_deltas(&first.threads, &second.threads);
1388 let delta = deltas
1389 .threads
1390 .iter()
1391 .find(|d| d.thread_id == id)
1392 .expect("the probe thread has a delta");
1393 assert!(!delta.started_in_window);
1394 assert!(delta.wakeups.unwrap() >= 1, "{delta:?}");
1395 }
1396
1397 #[cfg(all(windows, target_pointer_width = "64"))]
1399 #[test]
1400 fn windows_idle_thread_deltas_reconcile_with_the_process_delta() {
1401 let observed = observe_idle_with_threads(Duration::from_millis(50)).unwrap();
1402 assert!(observed.observation.elapsed >= Duration::from_millis(50));
1403 let start = observed.observation.start.wakeups_total.unwrap() as i128;
1404 let end = observed.observation.end.wakeups_total.unwrap() as i128;
1405 let listed = observed.threads.total_wakeups().unwrap() as i128;
1406 let exited = observed.threads.exited_wakeups_at_start().unwrap() as i128;
1407 assert_eq!(listed - exited, end - start);
1408 }
1409}