1use 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 loop {
102 let report = collect_report(samples)?;
103 if opts.json {
104 print_report(&report, true);
105 } else {
106 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 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, 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 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 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 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; 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 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 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 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 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 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}