Skip to main content

xbp_cli/commands/
resource_monitor.rs

1//! `xbp resource` — live CPU / RAM / disk-cache report for all XBP processes.
2//!
3//! Surfaces:
4//! - every running `xbp` process (role, cmdline, CPU%, RSS, virtual, threads)
5//! - on-disk cache under `~/.xbp` (mutations spool, logs, openapi, ssh, …)
6//! - worktree-watch schedule + rank summary from global config
7//! - this process's own allocation snapshot
8
9use crate::config::{resolve_global_xbp_root_dir, resolve_worktree_watch_config};
10use chrono::Utc;
11use colored::Colorize;
12use serde::Serialize;
13use std::collections::BTreeMap;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::thread;
17use std::time::Duration;
18use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
19
20#[derive(Debug, Clone)]
21pub struct ResourceMonitorOptions {
22    pub json: bool,
23    pub watch_seconds: Option<u64>,
24    pub samples: u32,
25}
26
27#[derive(Debug, Serialize)]
28#[serde(rename_all = "camelCase")]
29struct ResourceReport {
30    captured_at: String,
31    this_process: ProcessRow,
32    xbp_processes: Vec<ProcessRow>,
33    totals: ProcessTotals,
34    disk_cache: DiskCacheReport,
35    worktree_watch: WorktreeWatchResourceSummary,
36    notes: Vec<String>,
37}
38
39#[derive(Debug, Serialize, Clone)]
40#[serde(rename_all = "camelCase")]
41struct ProcessRow {
42    pid: u32,
43    role: String,
44    name: String,
45    cpu_percent: f64,
46    rss_mb: f64,
47    virtual_mb: f64,
48    threads: Option<usize>,
49    cmdline: String,
50    exe: Option<String>,
51}
52
53#[derive(Debug, Serialize, Default)]
54#[serde(rename_all = "camelCase")]
55struct ProcessTotals {
56    process_count: usize,
57    cpu_percent: f64,
58    rss_mb: f64,
59    virtual_mb: f64,
60}
61
62#[derive(Debug, Serialize, Default)]
63#[serde(rename_all = "camelCase")]
64struct DiskCacheReport {
65    root: String,
66    total_mb: f64,
67    file_count: u64,
68    directories: BTreeMap<String, DirSize>,
69}
70
71#[derive(Debug, Serialize, Default, Clone)]
72#[serde(rename_all = "camelCase")]
73struct DirSize {
74    mb: f64,
75    files: u64,
76}
77
78#[derive(Debug, Serialize, Default)]
79#[serde(rename_all = "camelCase")]
80struct WorktreeWatchResourceSummary {
81    parent_cover: Option<String>,
82    repo_rank_count: usize,
83    tier_counts: BTreeMap<String, u64>,
84    schedule: BTreeMap<String, u64>,
85    estimated_fs_watches_max: u64,
86    spool_mutations_mb: f64,
87    spool_mutations_files: u64,
88}
89
90pub async fn run_resource_monitor(opts: ResourceMonitorOptions) -> Result<(), String> {
91    let samples = opts.samples.max(1);
92    let interval = opts.watch_seconds.unwrap_or(0);
93
94    if interval == 0 {
95        let report = collect_report(samples)?;
96        print_report(&report, opts.json);
97        return Ok(());
98    }
99
100    // Continuous mode: clear-ish re-render every N seconds until Ctrl+C.
101    loop {
102        let report = collect_report(samples)?;
103        if opts.json {
104            print_report(&report, true);
105        } else {
106            // ANSI clear screen + home for a dashboard feel.
107            print!("\x1b[2J\x1b[H");
108            print_report(&report, false);
109            println!(
110                "\n{} refresh every {}s · Ctrl+C to stop",
111                "·".bright_black(),
112                interval
113            );
114        }
115        tokio::time::sleep(Duration::from_secs(interval)).await;
116    }
117}
118
119fn collect_report(cpu_samples: u32) -> Result<ResourceReport, String> {
120    let mut notes = Vec::new();
121    let processes = sample_xbp_processes(cpu_samples, &mut notes);
122    let this_pid = std::process::id();
123    let this_process = processes
124        .iter()
125        .find(|p| p.pid == this_pid)
126        .cloned()
127        .unwrap_or_else(|| ProcessRow {
128            pid: this_pid,
129            role: "cli".into(),
130            name: "xbp".into(),
131            cpu_percent: 0.0,
132            rss_mb: 0.0,
133            virtual_mb: 0.0,
134            threads: None,
135            cmdline: std::env::args().collect::<Vec<_>>().join(" "),
136            exe: std::env::current_exe()
137                .ok()
138                .map(|p| p.display().to_string()),
139        });
140
141    let totals = ProcessTotals {
142        process_count: processes.len(),
143        cpu_percent: round1(processes.iter().map(|p| p.cpu_percent).sum()),
144        rss_mb: round1(processes.iter().map(|p| p.rss_mb).sum()),
145        virtual_mb: round1(processes.iter().map(|p| p.virtual_mb).sum()),
146    };
147
148    let disk_cache = measure_disk_cache();
149    let worktree_watch = summarize_worktree_watch(&disk_cache);
150
151    if processes.len() <= 1 {
152        notes.push(
153            "Only this CLI sample is running — start worktree-watch with `xbp worktree-watch start --detach` to monitor the long-lived watcher."
154                .into(),
155        );
156    }
157    if totals.rss_mb > 200.0 {
158        notes.push(
159            "Aggregate RSS is high — prefer a release build (`cargo build -p xbp --release`) and tighter worktree_watch knobs (max_commit_checks_per_tick / max_fs_watches).".into(),
160        );
161    }
162
163    Ok(ResourceReport {
164        captured_at: Utc::now().to_rfc3339(),
165        this_process,
166        xbp_processes: processes,
167        totals,
168        disk_cache,
169        worktree_watch,
170        notes,
171    })
172}
173
174fn sample_xbp_processes(samples: u32, notes: &mut Vec<String>) -> Vec<ProcessRow> {
175    let mut system = System::new();
176    let refresh = ProcessRefreshKind::everything()
177        .with_cmd(UpdateKind::Always)
178        .with_exe(UpdateKind::Always)
179        .with_memory();
180
181    // Multi-sample CPU (sysinfo needs ≥2 refreshes for meaningful %).
182    let rounds = samples.max(2);
183    for i in 0..rounds {
184        system.refresh_processes_specifics(ProcessesToUpdate::All, true, refresh);
185        if i + 1 < rounds {
186            thread::sleep(Duration::from_millis(250));
187        }
188    }
189
190    let mut rows = Vec::new();
191    for (pid, proc) in system.processes() {
192        if !process_is_xbp(proc) {
193            continue;
194        }
195        let pid_u = pid.as_u32();
196        let cmd_parts: Vec<String> = proc
197            .cmd()
198            .iter()
199            .map(|s| s.to_string_lossy().into_owned())
200            .collect();
201        let cmdline = if cmd_parts.is_empty() {
202            proc.name().to_string_lossy().into_owned()
203        } else {
204            cmd_parts.join(" ")
205        };
206        let role = classify_role(&cmd_parts, &cmdline);
207        let rss = proc.memory() as f64 / (1024.0 * 1024.0);
208        let virt = proc.virtual_memory() as f64 / (1024.0 * 1024.0);
209        rows.push(ProcessRow {
210            pid: pid_u,
211            role,
212            name: proc.name().to_string_lossy().into_owned(),
213            cpu_percent: round1(f64::from(proc.cpu_usage())),
214            rss_mb: round1(rss),
215            virtual_mb: round1(virt),
216            threads: None, // sysinfo Process no longer always exposes thread count portably
217            cmdline: truncate_middle(&cmdline, 140),
218            exe: proc
219                .exe()
220                .map(|p| p.display().to_string()),
221        });
222    }
223
224    rows.sort_by(|a, b| {
225        b.rss_mb
226            .partial_cmp(&a.rss_mb)
227            .unwrap_or(std::cmp::Ordering::Equal)
228            .then_with(|| a.pid.cmp(&b.pid))
229    });
230
231    if rows.is_empty() {
232        notes.push("No xbp processes matched (unexpected — at least this CLI should appear).".into());
233    }
234    rows
235}
236
237fn process_is_xbp(proc: &sysinfo::Process) -> bool {
238    // Match by process name / executable only — never by cmdline substring alone
239    // (that falsely includes PowerShell/cmd wrappers whose args contain "xbp").
240    let name = proc.name().to_string_lossy().to_ascii_lowercase();
241    if name == "xbp" || name == "xbp.exe" {
242        return true;
243    }
244    if let Some(exe) = proc.exe() {
245        let s = exe.to_string_lossy().to_ascii_lowercase().replace('\\', "/");
246        if s.ends_with("/xbp.exe") || s.ends_with("/xbp") {
247            return true;
248        }
249    }
250    false
251}
252
253fn classify_role(cmd: &[String], joined: &str) -> String {
254    let lower = joined.to_ascii_lowercase();
255    if lower.contains("worktree-watch") {
256        if lower.contains(" tray") || lower.contains("\\tray") || lower.contains(" tray") {
257            return "worktree-watch tray".into();
258        }
259        if lower.contains("--parent") {
260            return "worktree-watch parent".into();
261        }
262        return "worktree-watch".into();
263    }
264    if std::env::var_os("PORT_XBP_MCP").is_some()
265        || lower.contains("mcp") && lower.contains("serve")
266    {
267        return "mcp".into();
268    }
269    if std::env::var_os("PORT_XBP_API").is_some() || lower.contains(" api") {
270        // only if this is the current process with PORT set
271        if cmd.iter().any(|c| c.contains("api")) {
272            return "api".into();
273        }
274    }
275    if lower.contains(" resource") {
276        return "resource-monitor".into();
277    }
278    "cli".into()
279}
280
281fn measure_disk_cache() -> DiskCacheReport {
282    let root = resolve_global_xbp_root_dir();
283    let mut directories = BTreeMap::new();
284    let mut total_bytes: u64 = 0;
285    let mut total_files: u64 = 0;
286
287    if root.is_dir() {
288        // Top-level entries
289        if let Ok(rd) = fs::read_dir(&root) {
290            for ent in rd.flatten() {
291                let path = ent.path();
292                let name = ent.file_name().to_string_lossy().into_owned();
293                let size = dir_size(&path);
294                total_bytes = total_bytes.saturating_add(size.bytes);
295                total_files = total_files.saturating_add(size.files);
296                directories.insert(
297                    name,
298                    DirSize {
299                        mb: round1(size.bytes as f64 / (1024.0 * 1024.0)),
300                        files: size.files,
301                    },
302                );
303            }
304        }
305    }
306
307    DiskCacheReport {
308        root: root.display().to_string(),
309        total_mb: round1(total_bytes as f64 / (1024.0 * 1024.0)),
310        file_count: total_files,
311        directories,
312    }
313}
314
315struct RawSize {
316    bytes: u64,
317    files: u64,
318}
319
320fn dir_size(path: &Path) -> RawSize {
321    if path.is_file() {
322        let bytes = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
323        return RawSize { bytes, files: 1 };
324    }
325    let mut bytes = 0u64;
326    let mut files = 0u64;
327    let mut stack = vec![path.to_path_buf()];
328    let mut visited = 0u32;
329    const MAX_ENTRIES: u32 = 200_000; // safety for huge spools
330    while let Some(dir) = stack.pop() {
331        let Ok(rd) = fs::read_dir(&dir) else {
332            continue;
333        };
334        for ent in rd.flatten() {
335            visited += 1;
336            if visited > MAX_ENTRIES {
337                return RawSize { bytes, files };
338            }
339            let p = ent.path();
340            if p.is_dir() {
341                stack.push(p);
342            } else if let Ok(meta) = ent.metadata() {
343                bytes = bytes.saturating_add(meta.len());
344                files = files.saturating_add(1);
345            }
346        }
347    }
348    RawSize { bytes, files }
349}
350
351fn summarize_worktree_watch(disk: &DiskCacheReport) -> WorktreeWatchResourceSummary {
352    let cfg = resolve_worktree_watch_config();
353    let mut tier_counts: BTreeMap<String, u64> = BTreeMap::new();
354    for r in &cfg.repo_ranks {
355        let tier = r
356            .tier
357            .as_deref()
358            .unwrap_or("unknown")
359            .to_ascii_lowercase();
360        *tier_counts.entry(tier).or_default() += 1;
361    }
362
363    let mut schedule = BTreeMap::new();
364    schedule.insert("idleAfterSeconds".into(), cfg.idle_after_seconds());
365    schedule.insert("coldAfterSeconds".into(), cfg.cold_after_seconds());
366    schedule.insert("hotCommitCheckSeconds".into(), cfg.hot_commit_check_seconds());
367    schedule.insert(
368        "warmCommitCheckSeconds".into(),
369        cfg.warm_commit_check_seconds(),
370    );
371    schedule.insert(
372        "coldCommitCheckSeconds".into(),
373        cfg.cold_commit_check_seconds(),
374    );
375    schedule.insert("parentRescanSeconds".into(), cfg.parent_rescan_seconds());
376    schedule.insert("maxHotRepos".into(), cfg.max_hot_repos() as u64);
377    schedule.insert("maxFsWatches".into(), cfg.max_fs_watches() as u64);
378    schedule.insert(
379        "maxCommitChecksPerTick".into(),
380        cfg.max_commit_checks_per_tick() as u64,
381    );
382
383    let mutations = disk
384        .directories
385        .get("mutations")
386        .cloned()
387        .unwrap_or_default();
388
389    WorktreeWatchResourceSummary {
390        parent_cover: detect_parent_cover(),
391        repo_rank_count: cfg.repo_ranks.len(),
392        tier_counts,
393        schedule,
394        estimated_fs_watches_max: cfg.max_fs_watches() as u64,
395        spool_mutations_mb: mutations.mb,
396        spool_mutations_files: mutations.files,
397    }
398}
399
400fn detect_parent_cover() -> Option<String> {
401    // Parent watcher state files live under the spool root when running.
402    // Fall back to common Documents/GitHub if present.
403    let home = resolve_global_xbp_root_dir();
404    let state_hint = home.join("worktree-watch-parent.json");
405    if state_hint.is_file() {
406        if let Ok(raw) = fs::read_to_string(&state_hint) {
407            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
408                if let Some(p) = v.get("parent").and_then(|x| x.as_str()) {
409                    return Some(p.to_string());
410                }
411                if let Some(p) = v.get("parentRoot").and_then(|x| x.as_str()) {
412                    return Some(p.to_string());
413                }
414            }
415        }
416    }
417    None
418}
419
420fn print_report(report: &ResourceReport, json: bool) {
421    if json {
422        match serde_json::to_string_pretty(report) {
423            Ok(s) => println!("{s}"),
424            Err(e) => eprintln!("json encode failed: {e}"),
425        }
426        return;
427    }
428
429    println!(
430        "{}  {}",
431        "xbp resource".bright_cyan().bold(),
432        report.captured_at.bright_black()
433    );
434    println!();
435
436    // Totals
437    println!("{}", "processes".bright_white().bold());
438    println!(
439        "  {}  {} process(es)  ·  CPU {:.1}%  ·  RSS {:.1} MiB  ·  virt {:.0} MiB",
440        "Σ".bright_magenta(),
441        report.totals.process_count,
442        report.totals.cpu_percent,
443        report.totals.rss_mb,
444        report.totals.virtual_mb
445    );
446    println!();
447
448    // Table
449    println!(
450        "  {:<8} {:<22} {:>7} {:>9} {:>10}  {}",
451        "PID".bright_black(),
452        "ROLE".bright_black(),
453        "CPU%".bright_black(),
454        "RSS_MiB".bright_black(),
455        "VIRT_MiB".bright_black(),
456        "CMD".bright_black()
457    );
458    for p in &report.xbp_processes {
459        let self_mark = if p.pid == report.this_process.pid {
460            "*".bright_green().to_string()
461        } else {
462            " ".into()
463        };
464        println!(
465            "{self_mark} {:<7} {:<22} {:>7.1} {:>9.1} {:>10.0}  {}",
466            p.pid,
467            truncate_middle(&p.role, 22),
468            p.cpu_percent,
469            p.rss_mb,
470            p.virtual_mb,
471            p.cmdline.bright_black()
472        );
473    }
474    println!(
475        "  {} this CLI sample",
476        "*".bright_green()
477    );
478    println!();
479
480    // Disk
481    println!("{}", "disk cache (~/.xbp)".bright_white().bold());
482    println!(
483        "  root  {}  ·  {:.1} MiB  ·  {} file(s)",
484        report.disk_cache.root.bright_black(),
485        report.disk_cache.total_mb,
486        report.disk_cache.file_count
487    );
488    if !report.disk_cache.directories.is_empty() {
489        println!(
490            "  {:<22} {:>10} {:>8}",
491            "DIR".bright_black(),
492            "MiB".bright_black(),
493            "FILES".bright_black()
494        );
495        let mut dirs: Vec<_> = report.disk_cache.directories.iter().collect();
496        dirs.sort_by(|a, b| {
497            b.1.mb
498                .partial_cmp(&a.1.mb)
499                .unwrap_or(std::cmp::Ordering::Equal)
500        });
501        for (name, size) in dirs.into_iter().take(16) {
502            println!("  {:<22} {:>10.1} {:>8}", name, size.mb, size.files);
503        }
504    }
505    println!();
506
507    // Worktree-watch config-side summary
508    let ww = &report.worktree_watch;
509    println!("{}", "worktree-watch (config + spool)".bright_white().bold());
510    if let Some(p) = &ww.parent_cover {
511        println!("  parent cover  {}", p.cyan());
512    }
513    println!(
514        "  ranks {}  ·  spool mutations {:.1} MiB / {} files  ·  max FS watches {}",
515        ww.repo_rank_count, ww.spool_mutations_mb, ww.spool_mutations_files, ww.estimated_fs_watches_max
516    );
517    if !ww.tier_counts.is_empty() {
518        let tiers: Vec<String> = ww
519            .tier_counts
520            .iter()
521            .map(|(k, v)| format!("{k}={v}"))
522            .collect();
523        println!("  tiers  {}", tiers.join(" · "));
524    }
525    if !ww.schedule.is_empty() {
526        println!(
527            "  schedule  hot/warm/cold HEAD {}/{}/{}s  ·  cold_after {}s  ·  commit_budget {}",
528            ww.schedule.get("hotCommitCheckSeconds").copied().unwrap_or(0),
529            ww.schedule
530                .get("warmCommitCheckSeconds")
531                .copied()
532                .unwrap_or(0),
533            ww.schedule
534                .get("coldCommitCheckSeconds")
535                .copied()
536                .unwrap_or(0),
537            ww.schedule.get("coldAfterSeconds").copied().unwrap_or(0),
538            ww.schedule
539                .get("maxCommitChecksPerTick")
540                .copied()
541                .unwrap_or(0),
542        );
543    }
544    println!();
545
546    if !report.notes.is_empty() {
547        println!("{}", "notes".bright_white().bold());
548        for n in &report.notes {
549            println!("  {} {}", "·".bright_yellow(), n);
550        }
551    }
552}
553
554fn round1(v: f64) -> f64 {
555    (v * 10.0).round() / 10.0
556}
557
558fn truncate_middle(s: &str, max: usize) -> String {
559    if s.chars().count() <= max {
560        return s.to_string();
561    }
562    if max < 5 {
563        return s.chars().take(max).collect();
564    }
565    let keep = max.saturating_sub(1) / 2;
566    let front: String = s.chars().take(keep).collect();
567    let back: String = s.chars().rev().take(keep).collect::<String>().chars().rev().collect();
568    format!("{front}…{back}")
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn classify_role_detects_worktree_parent() {
577        let cmd = vec![
578            "xbp.exe".into(),
579            "worktree-watch".into(),
580            "start".into(),
581            "--parent".into(),
582            r"C:\Users\me\Documents\GitHub".into(),
583        ];
584        let joined = cmd.join(" ");
585        assert_eq!(classify_role(&cmd, &joined), "worktree-watch parent");
586    }
587
588    #[test]
589    fn dir_size_counts_file() {
590        let dir = std::env::temp_dir().join(format!("xbp-resource-test-{}", std::process::id()));
591        let _ = fs::remove_dir_all(&dir);
592        fs::create_dir_all(&dir).unwrap();
593        fs::write(dir.join("a.txt"), b"hello-world").unwrap();
594        let s = dir_size(&dir);
595        assert_eq!(s.files, 1);
596        assert!(s.bytes >= 11);
597        let _ = fs::remove_dir_all(&dir);
598    }
599}