1use std::cell::RefCell;
19use std::collections::BTreeMap;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::sync::{Arc, Mutex, OnceLock};
22use std::thread;
23use std::time::{Duration, Instant};
24
25const PROCESS_MONITOR_INTERVAL: Duration = Duration::from_secs(60);
26
27const PROCESS_MONITOR_MAX_PHASE_LINES: usize = 8;
31
32const PROCESS_MONITOR_STALL_THRESHOLD: Duration = Duration::from_secs(120);
36
37static PROCESS_MONITOR: OnceLock<Arc<ProcessMonitorState>> = OnceLock::new();
38
39thread_local! {
40 static THREAD_STACK: RefCell<ThreadStack> = RefCell::new(ThreadStack::new());
41}
42
43#[derive(Clone)]
51pub struct ScopeProgress {
52 inner: Arc<ProgressCounter>,
53}
54
55struct ProgressCounter {
56 current: AtomicU64,
57 total: AtomicU64,
58}
59
60impl ScopeProgress {
61 pub fn set(&self, current: u64, total: u64) {
64 self.inner.current.store(current, Ordering::Relaxed);
65 self.inner.total.store(total, Ordering::Relaxed);
66 }
67
68 fn snapshot(&self) -> (u64, u64) {
69 (
70 self.inner.current.load(Ordering::Relaxed),
71 self.inner.total.load(Ordering::Relaxed),
72 )
73 }
74}
75
76#[derive(Clone)]
77struct FrameSnapshot {
78 label: String,
79 entered: Instant,
80 progress: Option<ScopeProgress>,
81}
82
83struct ThreadSnapshot {
84 name: Option<String>,
85 stack: Vec<FrameSnapshot>,
86 updated: Instant,
87}
88
89struct ProcessMonitorState {
90 started: Instant,
91 threads: Mutex<BTreeMap<String, ThreadSnapshot>>,
92 cpu: Mutex<CpuSampler>,
93}
94
95struct ThreadStack {
96 id: String,
97 name: Option<String>,
98 stack: Vec<FrameSnapshot>,
99}
100
101pub struct ProcessScopeGuard;
102
103impl ThreadStack {
104 fn new() -> Self {
105 let thread = thread::current();
106 Self {
107 id: format!("{:?}", thread.id()),
108 name: thread.name().map(str::to_string),
109 stack: Vec::new(),
110 }
111 }
112}
113
114impl ProcessMonitorState {
115 fn update_thread(&self, thread: &ThreadStack) {
116 let mut threads = self
117 .threads
118 .lock()
119 .expect("process monitor registry poisoned");
120 if thread.stack.is_empty() {
121 threads.remove(&thread.id);
122 } else {
123 threads.insert(
124 thread.id.clone(),
125 ThreadSnapshot {
126 name: thread.name.clone(),
127 stack: thread.stack.clone(),
128 updated: Instant::now(),
129 },
130 );
131 }
132 }
133
134 fn emit(&self) {
135 let threads = self
136 .threads
137 .lock()
138 .expect("process monitor registry poisoned");
139 let resource = ProcessResourceSnapshot::read();
140
141 let cpu = self
149 .cpu
150 .lock()
151 .expect("process monitor cpu sampler poisoned")
152 .sample();
153 let instrumented_threads = threads.len();
154
155 struct ThreadPhase<'a> {
160 thread_label: String,
161 depth: usize,
162 updated_ago: Duration,
163 deepest_label: &'a str,
164 deepest_age: Duration,
165 progress: Option<(u64, u64)>,
166 }
167 let mut phases: Vec<ThreadPhase<'_>> = Vec::with_capacity(threads.len());
168 for (thread_id, thread) in threads.iter() {
169 let Some(deepest) = thread.stack.last() else {
170 continue;
171 };
172 let thread_label = match &thread.name {
173 Some(name) => format!("{thread_id}/{name}"),
174 None => thread_id.clone(),
175 };
176 let progress = thread
180 .stack
181 .iter()
182 .rev()
183 .find_map(|frame| frame.progress.as_ref())
184 .map(ScopeProgress::snapshot);
185 phases.push(ThreadPhase {
186 thread_label,
187 depth: thread.stack.len(),
188 updated_ago: thread.updated.elapsed(),
189 deepest_label: deepest.label.as_str(),
190 deepest_age: deepest.entered.elapsed(),
191 progress,
192 });
193 }
194 phases.sort_by(|a, b| b.deepest_age.cmp(&a.deepest_age));
195
196 let active = match phases.first() {
200 Some(phase) => {
201 let progress = match phase.progress {
202 Some((cur, total)) if total > 0 => {
203 let pct = (cur as f64 / total as f64) * 100.0;
204 format!(" progress={cur}/{total} ({pct:.0}%)")
205 }
206 Some((cur, _)) => format!(" progress={cur}/?"),
207 None => String::new(),
208 };
209 format!(
210 " active={:?} for {}{}",
211 phase.deepest_label,
212 format_duration(phase.deepest_age),
213 progress,
214 )
215 }
216 None => " active=<idle>".to_string(),
217 };
218
219 log::info!(
220 "[process-monitor] elapsed={} {} {} instrumented_threads={}{}",
221 format_duration(self.started.elapsed()),
222 resource.format(),
223 cpu.format(),
224 instrumented_threads,
225 active,
226 );
227
228 for phase in phases
231 .iter()
232 .filter(|p| p.deepest_age >= PROCESS_MONITOR_STALL_THRESHOLD)
233 {
234 log::warn!(
235 "[process-monitor][STALL] thread={} phase={:?} stuck={}",
236 phase.thread_label,
237 phase.deepest_label,
238 format_duration(phase.deepest_age),
239 );
240 }
241
242 for phase in phases.iter().take(PROCESS_MONITOR_MAX_PHASE_LINES) {
244 let progress = match phase.progress {
245 Some((cur, total)) if total > 0 => {
246 let pct = (cur as f64 / total as f64) * 100.0;
247 format!(" progress={cur}/{total} ({pct:.0}%)")
248 }
249 Some((cur, _)) => format!(" progress={cur}/?"),
250 None => String::new(),
251 };
252 log::info!(
253 "[process-monitor] phase thread={} depth={} deepest={:?} in_frame={} updated_ago={}{}",
254 phase.thread_label,
255 phase.depth,
256 phase.deepest_label,
257 format_duration(phase.deepest_age),
258 format_duration(phase.updated_ago),
259 progress,
260 );
261 }
262 if phases.len() > PROCESS_MONITOR_MAX_PHASE_LINES {
263 log::info!(
264 "[process-monitor] phase ... and {} more active thread(s) omitted",
265 phases.len() - PROCESS_MONITOR_MAX_PHASE_LINES,
266 );
267 }
268 }
269}
270
271impl Drop for ProcessScopeGuard {
272 fn drop(&mut self) {
273 let state = process_monitor();
274 THREAD_STACK.with(|stack| {
275 let mut stack = stack.borrow_mut();
276 stack.stack.pop();
277 state.update_thread(&stack);
278 });
279 }
280}
281
282pub fn start() {
284 process_monitor();
285}
286
287pub fn track_scope(label: impl Into<String>) -> ProcessScopeGuard {
288 push_scope(label.into(), None)
289}
290
291fn push_scope(label: String, progress: Option<ScopeProgress>) -> ProcessScopeGuard {
292 let state = process_monitor();
293 THREAD_STACK.with(|stack| {
294 let mut stack = stack.borrow_mut();
295 stack.stack.push(FrameSnapshot {
296 label,
297 entered: Instant::now(),
298 progress,
299 });
300 state.update_thread(&stack);
301 });
302 ProcessScopeGuard
303}
304
305fn process_monitor() -> Arc<ProcessMonitorState> {
306 PROCESS_MONITOR
307 .get_or_init(|| {
308 let state = Arc::new(ProcessMonitorState {
309 started: Instant::now(),
310 threads: Mutex::new(BTreeMap::new()),
311 cpu: Mutex::new(CpuSampler::new()),
312 });
313 start_process_monitor_thread(Arc::clone(&state));
314 state
315 })
316 .clone()
317}
318
319fn start_process_monitor_thread(state: Arc<ProcessMonitorState>) {
320 let builder = thread::Builder::new().name("gam-process-monitor".to_string());
321 match builder.spawn(move || {
322 loop {
323 thread::park_timeout(PROCESS_MONITOR_INTERVAL);
324 state.emit();
325 }
326 }) {
327 Ok(handle) => drop(handle),
328 Err(err) => log::warn!("failed to start process monitor thread: {err}"),
329 }
330}
331
332fn format_duration(duration: Duration) -> String {
333 let total = duration.as_secs();
334 let hours = total / 3600;
335 let minutes = (total % 3600) / 60;
336 let seconds = total % 60;
337 if hours > 0 {
338 format!("{hours}h{minutes:02}m{seconds:02}s")
339 } else if minutes > 0 {
340 format!("{minutes}m{seconds:02}s")
341 } else {
342 format!("{seconds}s")
343 }
344}
345
346struct CpuSampler {
353 prev_total_ticks: Option<u64>,
354 prev_wall: Option<Instant>,
355 last_cores: Option<f64>,
356}
357
358impl CpuSampler {
359 fn new() -> Self {
360 Self {
361 prev_total_ticks: None,
362 prev_wall: None,
363 last_cores: None,
364 }
365 }
366
367 fn sample(&mut self) -> CpuSnapshot {
368 let now = Instant::now();
369 let ticks = read_self_cpu_ticks();
370 let cores = match (ticks, self.prev_total_ticks, self.prev_wall) {
371 (Some(ticks), Some(prev_ticks), Some(prev_wall)) => {
372 let delta_ticks = ticks.saturating_sub(prev_ticks) as f64;
373 let delta_wall = now.duration_since(prev_wall).as_secs_f64();
374 let hz = clock_ticks_per_second();
375 if delta_wall > 0.0 && hz > 0.0 {
376 let cores = delta_ticks / hz / delta_wall;
377 self.last_cores = Some(cores);
378 Some(cores)
379 } else {
380 self.last_cores
381 }
382 }
383 _ => None,
384 };
385 if let Some(ticks) = ticks {
386 self.prev_total_ticks = Some(ticks);
387 self.prev_wall = Some(now);
388 }
389 CpuSnapshot {
390 cores,
391 ncpu: available_parallelism(),
392 window: PROCESS_MONITOR_INTERVAL,
393 }
394 }
395}
396
397struct CpuSnapshot {
398 cores: Option<f64>,
399 ncpu: Option<usize>,
400 window: Duration,
401}
402
403impl CpuSnapshot {
404 fn format(&self) -> String {
405 match self.cores {
406 Some(cores) => {
407 let of = match self.ncpu {
408 Some(n) => format!("/{n}"),
409 None => String::new(),
410 };
411 format!(
412 "cpu={:.1}{} cores (avg over {})",
413 cores,
414 of,
415 format_duration(self.window),
416 )
417 }
418 None => "cpu=<warming-up>".to_string(),
419 }
420 }
421}
422
423#[cfg(target_os = "linux")]
430fn read_self_cpu_ticks() -> Option<u64> {
431 let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
432 let after_comm = stat.rsplit_once(')')?.1;
433 let fields: Vec<&str> = after_comm.split_whitespace().collect();
436 let utime: u64 = fields.get(11)?.parse().ok()?;
437 let stime: u64 = fields.get(12)?.parse().ok()?;
438 Some(utime.saturating_add(stime))
439}
440
441#[cfg(not(target_os = "linux"))]
442fn read_self_cpu_ticks() -> Option<u64> {
443 None
444}
445
446#[cfg(target_os = "linux")]
451fn clock_ticks_per_second() -> f64 {
452 100.0
453}
454
455#[cfg(not(target_os = "linux"))]
456fn clock_ticks_per_second() -> f64 {
457 0.0
458}
459
460fn available_parallelism() -> Option<usize> {
461 thread::available_parallelism().ok().map(|n| n.get())
462}
463
464#[derive(Default)]
465struct ProcessResourceSnapshot {
466 rss_kb: Option<u64>,
467 peak_rss_kb: Option<u64>,
468 threads: Option<u64>,
469 read_bytes: Option<u64>,
470 write_bytes: Option<u64>,
471}
472
473impl ProcessResourceSnapshot {
474 fn read() -> Self {
475 #[cfg(target_os = "linux")]
476 {
477 Self::read_linux()
478 }
479 #[cfg(not(target_os = "linux"))]
480 {
481 Self::default()
482 }
483 }
484
485 fn format(&self) -> String {
486 format!(
487 "rss={} peak_rss={} process_threads={} read_bytes={} write_bytes={}",
488 format_kb(self.rss_kb),
489 format_kb(self.peak_rss_kb),
490 format_count(self.threads),
491 format_bytes(self.read_bytes),
492 format_bytes(self.write_bytes),
493 )
494 }
495
496 #[cfg(target_os = "linux")]
497 fn read_linux() -> Self {
498 let mut snapshot = Self::default();
499 if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
500 for line in status.lines() {
501 if let Some(value) = parse_status_kb(line, "VmRSS:") {
502 snapshot.rss_kb = Some(value);
503 } else if let Some(value) = parse_status_kb(line, "VmHWM:") {
504 snapshot.peak_rss_kb = Some(value);
505 } else if let Some(value) = parse_status_count(line, "Threads:") {
506 snapshot.threads = Some(value);
507 }
508 }
509 }
510 if let Ok(io) = std::fs::read_to_string("/proc/self/io") {
511 for line in io.lines() {
512 if let Some(value) = parse_io_bytes(line, "read_bytes:") {
513 snapshot.read_bytes = Some(value);
514 } else if let Some(value) = parse_io_bytes(line, "write_bytes:") {
515 snapshot.write_bytes = Some(value);
516 }
517 }
518 }
519 snapshot
520 }
521}
522
523#[cfg(target_os = "linux")]
524fn parse_status_kb(line: &str, key: &str) -> Option<u64> {
525 let rest = line.strip_prefix(key)?.trim();
526 rest.split_whitespace().next()?.parse().ok()
527}
528
529#[cfg(target_os = "linux")]
530fn parse_status_count(line: &str, key: &str) -> Option<u64> {
531 let rest = line.strip_prefix(key)?.trim();
532 rest.split_whitespace().next()?.parse().ok()
533}
534
535#[cfg(target_os = "linux")]
536fn parse_io_bytes(line: &str, key: &str) -> Option<u64> {
537 let rest = line.strip_prefix(key)?.trim();
538 rest.parse().ok()
539}
540
541fn format_count(value: Option<u64>) -> String {
542 value
543 .map(|value| value.to_string())
544 .unwrap_or_else(|| "<unknown>".to_string())
545}
546
547fn format_kb(value: Option<u64>) -> String {
548 value
549 .map(|kb| format_bytes(Some(kb.saturating_mul(1024))))
550 .unwrap_or_else(|| "<unknown>".to_string())
551}
552
553fn format_bytes(value: Option<u64>) -> String {
554 let Some(bytes) = value else {
555 return "<unknown>".to_string();
556 };
557 const KIB: f64 = 1024.0;
558 const MIB: f64 = KIB * 1024.0;
559 const GIB: f64 = MIB * 1024.0;
560 let bytes_f = bytes as f64;
561 if bytes_f >= GIB {
562 format!("{:.1}GiB", bytes_f / GIB)
563 } else if bytes_f >= MIB {
564 format!("{:.1}MiB", bytes_f / MIB)
565 } else if bytes_f >= KIB {
566 format!("{:.1}KiB", bytes_f / KIB)
567 } else {
568 format!("{bytes}B")
569 }
570}
571
572#[cfg(test)]
573mod format_tests {
574 use super::*;
575 use std::time::Duration;
576
577 #[test]
580 fn format_duration_seconds_only() {
581 assert_eq!(format_duration(Duration::from_secs(45)), "45s");
582 }
583
584 #[test]
585 fn format_duration_minutes_and_seconds() {
586 assert_eq!(format_duration(Duration::from_secs(90)), "1m30s");
587 }
588
589 #[test]
590 fn format_duration_minutes_zero_seconds() {
591 assert_eq!(format_duration(Duration::from_secs(120)), "2m00s");
592 }
593
594 #[test]
595 fn format_duration_hours_minutes_seconds() {
596 assert_eq!(format_duration(Duration::from_secs(3661)), "1h01m01s");
597 }
598
599 #[test]
600 fn format_duration_exactly_one_hour() {
601 assert_eq!(format_duration(Duration::from_secs(3600)), "1h00m00s");
602 }
603
604 #[test]
605 fn format_duration_zero() {
606 assert_eq!(format_duration(Duration::from_secs(0)), "0s");
607 }
608
609 #[test]
612 fn format_count_some_value() {
613 assert_eq!(format_count(Some(42)), "42");
614 }
615
616 #[test]
617 fn format_count_zero() {
618 assert_eq!(format_count(Some(0)), "0");
619 }
620
621 #[test]
622 fn format_count_none_is_unknown() {
623 assert_eq!(format_count(None), "<unknown>");
624 }
625
626 #[test]
629 fn format_bytes_none_is_unknown() {
630 assert_eq!(format_bytes(None), "<unknown>");
631 }
632
633 #[test]
634 fn format_bytes_small_bytes() {
635 assert_eq!(format_bytes(Some(512)), "512B");
636 }
637
638 #[test]
639 fn format_bytes_exactly_1_kib() {
640 assert_eq!(format_bytes(Some(1024)), "1.0KiB");
641 }
642
643 #[test]
644 fn format_bytes_kib_range() {
645 assert_eq!(format_bytes(Some(2048)), "2.0KiB");
646 }
647
648 #[test]
649 fn format_bytes_exactly_1_mib() {
650 assert_eq!(format_bytes(Some(1024 * 1024)), "1.0MiB");
651 }
652
653 #[test]
654 fn format_bytes_exactly_1_gib() {
655 assert_eq!(format_bytes(Some(1024 * 1024 * 1024)), "1.0GiB");
656 }
657
658 #[test]
659 fn format_bytes_gib_range() {
660 assert_eq!(format_bytes(Some(2 * 1024 * 1024 * 1024)), "2.0GiB");
661 }
662
663 #[test]
666 fn format_kb_none_is_unknown() {
667 assert_eq!(format_kb(None), "<unknown>");
668 }
669
670 #[test]
671 fn format_kb_converts_to_bytes_and_formats() {
672 assert_eq!(format_kb(Some(1024)), "1.0MiB");
674 }
675
676 #[test]
677 fn format_kb_small_value() {
678 assert_eq!(format_kb(Some(1)), "1.0KiB");
680 }
681
682 #[cfg(target_os = "linux")]
685 #[test]
686 fn parse_status_kb_valid_line() {
687 assert_eq!(parse_status_kb("VmRSS:\t1234 kB", "VmRSS:"), Some(1234));
688 }
689
690 #[cfg(target_os = "linux")]
691 #[test]
692 fn parse_status_kb_wrong_key_returns_none() {
693 assert_eq!(parse_status_kb("VmRSS:\t1234 kB", "VmPeak:"), None);
694 }
695
696 #[cfg(target_os = "linux")]
697 #[test]
698 fn parse_status_count_valid_line() {
699 assert_eq!(
700 parse_status_count("voluntary_ctxt_switches:\t42", "voluntary_ctxt_switches:"),
701 Some(42)
702 );
703 }
704
705 #[cfg(target_os = "linux")]
706 #[test]
707 fn parse_io_bytes_valid_line() {
708 assert_eq!(
709 parse_io_bytes("read_bytes: 65536", "read_bytes:"),
710 Some(65536)
711 );
712 }
713
714 #[cfg(target_os = "linux")]
715 #[test]
716 fn parse_io_bytes_wrong_key_returns_none() {
717 assert_eq!(parse_io_bytes("read_bytes: 65536", "write_bytes:"), None);
718 }
719}