Skip to main content

gam_runtime/
process_monitor.rs

1//! Process-wide liveness monitor with per-thread scope stacks.
2//!
3//! Each heartbeat (≈once/minute) reports three things a user watching a long
4//! compute log actually needs:
5//!   1. The currently-active operation — the label of the longest-running
6//!      instrumented scope on any thread and how long it has been running, so a
7//!      multi-minute silent window shows `active="BMS coord_corrections …" for 142s`
8//!      instead of nothing.
9//!   2. A TRUE busy signal — process-wide CPU utilization in cores-busy,
10//!      computed from `/proc/self/stat` (utime+stime) deltas between heartbeats.
11//!      A rayon fan-out saturating ~70 cores reads as `cpu=68.3 cores`, where
12//!      the old `active_threads` counter only ever saw the handful of threads
13//!      inside an instrumented `track_scope` (rayon workers are not) and so
14//!      reported a misleading `0`.
15//!   3. Progress — when a long scope registers a progress counter (via
16//!      `track_scope_with_progress`) the heartbeat surfaces `progress=a/b (X%)`.
17
18use 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
27/// Maximum number of per-thread phase lines emitted in one periodic dump
28/// (ordered by deepest-frame age, oldest first); the remainder is summarized
29/// as a count so the dump stays readable on a many-threaded process.
30const PROCESS_MONITOR_MAX_PHASE_LINES: usize = 8;
31
32/// A thread whose deepest instrumented frame has been live longer than this is
33/// flagged with a loud `[process-monitor][STALL]` line so a long unlogged
34/// phase is impossible to miss in the log.
35const 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/// A shared progress counter a long-running scope can expose to the heartbeat.
44///
45/// A compute loop creates one via `track_scope_with_progress`, then bumps
46/// [`ScopeProgress::set`] / `ScopeProgress::inc` as it advances. The
47/// heartbeat reads the current/total atomically and surfaces a percentage in
48/// the active-scope line — no log-spam coupling between the loop and the
49/// monitor cadence.
50#[derive(Clone)]
51pub struct ScopeProgress {
52    inner: Arc<ProgressCounter>,
53}
54
55struct ProgressCounter {
56    current: AtomicU64,
57    total: AtomicU64,
58}
59
60impl ScopeProgress {
61    /// Record progress as `current` out of `total` units (e.g. rows processed
62    /// out of rows total). `total == 0` is treated as "total unknown".
63    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        // TRUE busy signal: process-wide cores-busy averaged over the interval
142        // since the last heartbeat, read from /proc/self/stat. Independent of
143        // whether the busy threads happen to sit inside an instrumented scope,
144        // so a rayon fan-out over ~70 cores reads as ~70 here. The old
145        // `active_threads` counter (now `instrumented_threads`, kept as a
146        // diagnostic) only ever counted threads inside a `track_scope` and so
147        // reported a misleading 0 during rayon-heavy windows.
148        let cpu = self
149            .cpu
150            .lock()
151            .expect("process monitor cpu sampler poisoned")
152            .sample();
153        let instrumented_threads = threads.len();
154
155        // Build a per-thread view keyed on the DEEPEST frame (the innermost
156        // phase the thread is actually executing) and how long it has been
157        // there. Order oldest-first so the most-likely-stalled phases sort to
158        // the top of the (capped) dump.
159        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            // Surface the first progress counter found walking the stack from
177            // the innermost frame outward (the innermost reporting scope is the
178            // most specific "what is it doing right now").
179            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        // Headline line: total elapsed, resource snapshot, true CPU busy
197        // signal, and — front and center — the longest-running active scope so
198        // a user instantly sees "what is it doing and for how long".
199        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        // STALL warnings first, loud and unconditional (not subject to the
229        // per-dump phase-line cap) so a long unlogged phase is never silent.
230        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        // Compact per-thread phase summary: deepest frame label + age, capped.
243        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
282/// Start the background process monitor thread if it is not already running.
283pub 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
346/// Process-wide CPU utilization sampler.
347///
348/// On Linux it reads cumulative user+system CPU jiffies from `/proc/self/stat`
349/// and, between consecutive heartbeats, computes the average number of cores
350/// kept busy over the interval: `Δ(utime+stime)/clock_hz / Δwall`. The first
351/// heartbeat has no prior sample and reports the busy figure as unknown.
352struct 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/// Cumulative user+system CPU jiffies for this process from `/proc/self/stat`.
424///
425/// `/proc/self/stat` is a single space-separated line; field 14 (`utime`) and
426/// field 15 (`stime`) are the process's user/system time in clock ticks. The
427/// process command name (field 2) is parenthesized and may itself contain
428/// spaces, so we split after the final `)`.
429#[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    // After the closing ')' the remaining fields start at field 3 (state), so
434    // utime is index 11 and stime is index 12 of the post-')' whitespace split.
435    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/// Clock ticks per second (`sysconf(_SC_CLK_TCK)`); on Linux this is almost
447/// universally 100. We hard-pin 100 rather than linking libc just for the
448/// sysconf call — the value is fixed at kernel build time and the standard
449/// Linux ABI value is 100, which is what `/proc` times are reported in.
450#[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    // ── format_duration ───────────────────────────────────────────────────────
578
579    #[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    // ── format_count ─────────────────────────────────────────────────────────
610
611    #[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    // ── format_bytes ──────────────────────────────────────────────────────────
627
628    #[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    // ── format_kb ─────────────────────────────────────────────────────────────
664
665    #[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        // 1024 kB = 1 MiB
673        assert_eq!(format_kb(Some(1024)), "1.0MiB");
674    }
675
676    #[test]
677    fn format_kb_small_value() {
678        // 1 kB = 1024 bytes → "1.0KiB"
679        assert_eq!(format_kb(Some(1)), "1.0KiB");
680    }
681
682    // ── parse_status_kb / parse_status_count / parse_io_bytes (Linux) ─────────
683
684    #[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}