1use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use sysinfo::{DiskRefreshKind, Disks, Networks, Pid, ProcessRefreshKind, ProcessesToUpdate};
13
14use crate::backend::blockcontroller::pidregistry;
15use crate::backend::blockcontroller::process_tree;
16use crate::backend::rpc_types::TimeSeriesData;
17use crate::backend::wconfig::ConfigState;
18use crate::backend::mps::Broker;
19use crate::backend::sampler::{Scheduler, SourceId, SourceSpec};
20
21const BYTES_PER_GB: f64 = 1_073_741_824.0;
22const BYTES_PER_MB: f64 = 1_048_576.0;
23const PERSIST_COUNT: usize = 1024;
24const DEFAULT_INTERVAL_SECS: f64 = 1.0;
25const MIN_INTERVAL_SECS: f64 = 0.2;
26const MAX_INTERVAL_SECS: f64 = 2.0;
27const DISK_FULL_REFRESH: Duration = Duration::from_secs(30);
34
35const SCHEDULER_COALESCE_MS: u64 = 50;
37const PANE_JANITOR_MS: u64 = 30_000;
40const NEVER_STRETCHED: u64 = u64::MAX / 4;
42const HOLDER_TELEMETRY: &str = "telemetry";
43const HOLDER_JANITOR: &str = "pane-janitor";
44const HOLDER_BADGES: &str = "pane-badges";
45
46#[cfg(windows)]
85fn suspend_aware_now_ms() -> u64 {
86 unsafe { windows_sys::Win32::System::SystemInformation::GetTickCount64() }
88}
89
90#[cfg(not(windows))]
91fn suspend_aware_now_ms() -> u64 {
92 #[cfg(any(target_os = "linux", target_os = "android"))]
93 const CLOCK: libc::clockid_t = libc::CLOCK_BOOTTIME;
94 #[cfg(not(any(target_os = "linux", target_os = "android")))]
95 const CLOCK: libc::clockid_t = libc::CLOCK_MONOTONIC;
96
97 let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
98 if unsafe { libc::clock_gettime(CLOCK, &mut ts) } != 0 {
100 return 0;
104 }
105 (ts.tv_sec as u64) * 1_000 + (ts.tv_nsec as u64) / 1_000_000
106}
107
108static PROCESS_START_MS: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
111
112pub fn mark_process_start() {
115 let _ = PROCESS_START_MS.set(suspend_aware_now_ms());
116}
117
118pub fn uptime_secs() -> u64 {
124 let start = *PROCESS_START_MS.get_or_init(suspend_aware_now_ms);
125 suspend_aware_now_ms().saturating_sub(start) / 1_000
126}
127
128#[cfg(target_os = "windows")]
130const ATTRIBUTION_INTERVAL: Duration = Duration::from_secs(30);
131#[cfg(target_os = "windows")]
135const ATTRIBUTION_URGENT_THRESHOLD_GB: f64 = 2.0;
136#[cfg(target_os = "windows")]
139const ATTRIBUTION_URGENT_COOLDOWN: Duration = Duration::from_secs(10);
140#[cfg(target_os = "windows")]
142const ATTRIBUTION_OTHER_TOP_N: usize = 5;
143#[cfg(target_os = "windows")]
154const ATTRIBUTION_MAX_PIDS_PER_BLOCK: usize = 4096;
155
156#[cfg(target_os = "windows")]
162struct ProcSample {
163 pid: u32,
164 name: String,
165 commit_mb: f64,
166}
167
168#[cfg(target_os = "windows")]
169struct AttributionResult {
170 agentmux_mb: f64,
171 panes_mb: f64,
172 other_mb: f64,
173 other_top: Vec<(String, f64)>,
175}
176
177#[cfg(target_os = "windows")]
187fn classify_commit_attribution(
188 samples: &[ProcSample],
189 pane_pids: &HashSet<u32>,
190 top_n: usize,
191) -> AttributionResult {
192 let mut agentmux_mb = 0.0;
193 let mut panes_mb = 0.0;
194 let mut other_by_name: HashMap<String, f64> = HashMap::new();
195 for s in samples {
196 if s.name.to_ascii_lowercase().starts_with("agentmux") {
197 agentmux_mb += s.commit_mb;
198 } else if pane_pids.contains(&s.pid) {
199 panes_mb += s.commit_mb;
200 } else {
201 *other_by_name.entry(s.name.clone()).or_insert(0.0) += s.commit_mb;
202 }
203 }
204 let mut other: Vec<(String, f64)> = other_by_name.into_iter().collect();
205 other.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
206 let other_mb: f64 = other.iter().map(|(_, mb)| mb).sum();
207 other.truncate(top_n);
208 AttributionResult {
209 agentmux_mb,
210 panes_mb,
211 other_mb,
212 other_top: other,
213 }
214}
215
216#[cfg(target_os = "windows")]
228const HANDLE_WARN_AGENTMUX: u32 = 20_000;
229#[cfg(target_os = "windows")]
230const HANDLE_WARN_EXTERNAL: u32 = 50_000;
231#[cfg(target_os = "windows")]
232const HANDLE_TOP_N: usize = 3;
233#[cfg(target_os = "windows")]
243const HANDLE_WARN_COOLDOWN: Duration = Duration::from_secs(600);
244
245#[cfg(target_os = "windows")]
247struct HandleSample {
248 pid: u32,
249 name: String,
250 handles: u32,
251}
252
253#[cfg(target_os = "windows")]
254struct HandleAnomaly {
255 pid: u32,
256 name: String,
257 handles: u32,
258 is_agentmux: bool,
262}
263
264#[cfg(target_os = "windows")]
269fn classify_handle_anomalies(samples: &[HandleSample]) -> Vec<HandleAnomaly> {
270 let mut anomalies: Vec<HandleAnomaly> = samples
271 .iter()
272 .filter_map(|s| {
273 let is_agentmux = s.name.to_ascii_lowercase().starts_with("agentmux");
274 let threshold = if is_agentmux { HANDLE_WARN_AGENTMUX } else { HANDLE_WARN_EXTERNAL };
275 (s.handles >= threshold).then(|| HandleAnomaly {
276 pid: s.pid,
277 name: s.name.clone(),
278 handles: s.handles,
279 is_agentmux,
280 })
281 })
282 .collect();
283 anomalies.sort_by(|a, b| b.handles.cmp(&a.handles));
284 anomalies
285}
286
287#[cfg(target_os = "windows")]
295fn filter_anomalies_for_warn(
296 anomalies: &[HandleAnomaly],
297 last_warned: &mut HashMap<u32, Instant>,
298 now: Instant,
299 cooldown: Duration,
300) -> Vec<usize> {
301 let current: HashSet<u32> = anomalies.iter().map(|a| a.pid).collect();
302 last_warned.retain(|pid, _| current.contains(pid));
303
304 let mut warn = Vec::new();
305 for (i, anomaly) in anomalies.iter().enumerate() {
306 let due = match last_warned.get(&anomaly.pid) {
307 Some(&at) => now.duration_since(at) >= cooldown,
308 None => true,
309 };
310 if due {
311 last_warned.insert(anomaly.pid, now);
312 warn.push(i);
313 }
314 }
315 warn
316}
317
318#[cfg(target_os = "windows")]
322static LAST_HANDLE_WARN: std::sync::OnceLock<std::sync::Mutex<HashMap<u32, Instant>>> =
323 std::sync::OnceLock::new();
324
325#[cfg(target_os = "windows")]
331pub(crate) fn process_handle_count(pid: u32) -> Option<u32> {
332 use windows_sys::Win32::Foundation::CloseHandle;
333 use windows_sys::Win32::System::Threading::{
334 GetProcessHandleCount, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
335 };
336 unsafe {
337 let h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
338 if h.is_null() {
339 return None;
340 }
341 let mut count: u32 = 0;
342 let ok = GetProcessHandleCount(h, &mut count);
343 CloseHandle(h);
344 (ok != 0).then_some(count)
345 }
346}
347
348#[cfg(target_os = "windows")]
359fn log_memory_attribution(sys: &mut sysinfo::System, commit_used_gb: f64, commit_total_gb: f64) {
360 sys.refresh_processes_specifics(
367 ProcessesToUpdate::All,
368 true, ProcessRefreshKind::nothing().with_memory(),
370 );
371
372 let mut pane_pids: HashSet<u32> = HashSet::new();
373 for (block_id, pid) in pidregistry::get_all() {
374 let tree = process_tree::collect_descendants(
375 sys,
376 Pid::from(pid as usize),
377 ATTRIBUTION_MAX_PIDS_PER_BLOCK,
378 );
379 if tree.len() == ATTRIBUTION_MAX_PIDS_PER_BLOCK {
380 tracing::warn!(
381 block_id = %block_id,
382 cap = ATTRIBUTION_MAX_PIDS_PER_BLOCK,
383 "log_memory_attribution: block's descendant PID tree hit the cap — \
384 some processes may be misclassified into \"other\" instead of \"panes\""
385 );
386 }
387 pane_pids.extend(tree.into_iter().map(|pid| pid.as_u32()));
388 }
389
390 let samples: Vec<ProcSample> = sys
391 .processes()
392 .iter()
393 .map(|(pid, proc)| ProcSample {
394 pid: pid.as_u32(),
395 name: proc.name().to_string_lossy().into_owned(),
396 commit_mb: proc.virtual_memory() as f64 / BYTES_PER_MB,
397 })
398 .collect();
399
400 let result = classify_commit_attribution(&samples, &pane_pids, ATTRIBUTION_OTHER_TOP_N);
401
402 let other_top_str = result
403 .other_top
404 .iter()
405 .map(|(name, mb)| format!("{}:{:.0}MB", name, mb))
406 .collect::<Vec<_>>()
407 .join(", ");
408
409 let attributed_gb = (result.agentmux_mb + result.panes_mb + result.other_mb) / 1024.0;
421 let unattributed_gb = (commit_used_gb - attributed_gb).max(0.0);
422
423 let handle_samples: Vec<HandleSample> = samples
427 .iter()
428 .filter_map(|s| {
429 process_handle_count(s.pid).map(|handles| HandleSample {
430 pid: s.pid,
431 name: s.name.clone(),
432 handles,
433 })
434 })
435 .collect();
436 let mut top_handles: Vec<&HandleSample> = handle_samples.iter().collect();
437 top_handles.sort_by(|a, b| b.handles.cmp(&a.handles));
438 let handles_top_str = top_handles
439 .iter()
440 .take(HANDLE_TOP_N)
441 .map(|s| format!("{}:{}:{}", s.name, s.pid, s.handles))
442 .collect::<Vec<_>>()
443 .join(", ");
444
445 let anomalies = classify_handle_anomalies(&handle_samples);
446 let warn_indices = {
447 let mut last = LAST_HANDLE_WARN
448 .get_or_init(|| std::sync::Mutex::new(HashMap::new()))
449 .lock()
450 .unwrap();
451 filter_anomalies_for_warn(&anomalies, &mut last, Instant::now(), HANDLE_WARN_COOLDOWN)
452 };
453 for i in warn_indices {
454 let anomaly = &anomalies[i];
455 if anomaly.is_agentmux {
456 tracing::warn!(
457 target: "mem_attribution",
458 pid = anomaly.pid,
459 name = %anomaly.name,
460 handles = anomaly.handles,
461 "possible handle leak in an AgentMux process — a healthy baseline is under ~1K; \
462 run `handle64 -s -p <pid>` for the type breakdown (a huge Section count is the \
463 known leak signature, docs/status/STATUS_SRV_SECTION_HANDLE_LEAK_2026_08_08.md)"
464 );
465 } else {
466 tracing::warn!(
467 target: "mem_attribution",
468 pid = anomaly.pid,
469 name = %anomaly.name,
470 handles = anomaly.handles,
471 "external process handle-count anomaly (Audiosrv-class OS leak?) — if commit is \
472 also climbing unattributed, restarting that process/service reclaims it \
473 (docs/status/STATUS_PF_COMMIT_GROWTH_INVESTIGATION_2026_07_24.md §16)"
474 );
475 }
476 }
477
478 let (tracker_blocks, tracker_pids) = crate::backend::process_tracker::registry::global()
487 .map(|r| r.stats())
488 .unwrap_or((0, 0));
489 let (reaper_candidates, reaper_first_seen, reaper_reaped_cumulative) =
490 crate::sagas::orphan_reaper::last_sweep_stats();
491
492 tracing::info!(
493 target: "mem_attribution",
494 commit_used_gb = format!("{:.2}", commit_used_gb),
495 commit_total_gb = format!("{:.2}", commit_total_gb),
496 agentmux_mb = format!("{:.0}", result.agentmux_mb),
497 panes_mb = format!("{:.0}", result.panes_mb),
498 other_mb = format!("{:.0}", result.other_mb),
499 other_top = %other_top_str,
500 unattributed_gb = format!("{:.2}", unattributed_gb),
501 handles_top = %handles_top_str,
502 process_count = samples.len(),
503 tracker_blocks = tracker_blocks,
504 tracker_pids = tracker_pids,
505 reaper_candidates = reaper_candidates,
506 reaper_first_seen = reaper_first_seen,
507 reaper_reaped_cumulative = reaper_reaped_cumulative,
508 "commit attribution"
509 );
510}
511
512fn get_cpu_data(sys: &sysinfo::System, values: &mut HashMap<String, f64>) {
514 let cpus = sys.cpus();
515 if cpus.is_empty() {
516 return;
517 }
518 let total: f64 = cpus.iter().map(|c| c.cpu_usage() as f64).sum::<f64>() / cpus.len() as f64;
520 values.insert("cpu".to_string(), total);
521 for (idx, cpu) in cpus.iter().enumerate() {
523 values.insert(format!("cpu:{}", idx), cpu.cpu_usage() as f64);
524 }
525}
526
527fn get_mem_data(sys: &sysinfo::System, values: &mut HashMap<String, f64>) {
529 let total = sys.total_memory() as f64 / BYTES_PER_GB;
530 let used = sys.used_memory() as f64 / BYTES_PER_GB;
531 let available = sys.available_memory() as f64 / BYTES_PER_GB;
532 let free = sys.free_memory() as f64 / BYTES_PER_GB;
533 values.insert("mem:total".to_string(), total);
534 values.insert("mem:used".to_string(), used);
535 values.insert("mem:available".to_string(), available);
536 values.insert("mem:free".to_string(), free);
537}
538
539fn get_commit_data(_values: &mut HashMap<String, f64>) {
543 #[cfg(target_os = "windows")]
544 {
545 use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
546 let mut mem: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
547 mem.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
548 if unsafe { GlobalMemoryStatusEx(&mut mem) } != 0 {
549 let total_gb = mem.ullTotalPageFile as f64 / BYTES_PER_GB;
550 let avail_gb = mem.ullAvailPageFile as f64 / BYTES_PER_GB;
551 _values.insert("mem:commit:used".to_string(), total_gb - avail_gb);
552 _values.insert("mem:commit:total".to_string(), total_gb);
553 }
554 }
555}
556
557pub fn available_commit_gb() -> Option<f64> {
563 #[cfg(target_os = "windows")]
564 {
565 use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
566 let mut mem: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
567 mem.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
568 if unsafe { GlobalMemoryStatusEx(&mut mem) } != 0 {
569 return Some(mem.ullAvailPageFile as f64 / BYTES_PER_GB);
570 }
571 }
572 None
573}
574
575fn get_pagefile_volume_data(_values: &mut HashMap<String, f64>) {
602 #[cfg(target_os = "windows")]
603 {
604 let (drive, system_managed) = agentmux_common::pagefile::pagefile_watch_target();
605 if let Some((free_gb, total_gb)) = agentmux_common::pagefile::drive_free_total_gb(drive) {
606 _values.insert("disk:watch:free_gb".to_string(), free_gb);
607 _values.insert("disk:watch:total_gb".to_string(), total_gb);
608 if total_gb > 0.0 {
609 _values.insert(
610 "disk:watch:free_pct".to_string(),
611 (free_gb / total_gb) * 100.0,
612 );
613 }
614 _values.insert(
615 "disk:pagefile_system_managed".to_string(),
616 if system_managed { 1.0 } else { 0.0 },
617 );
618 _values.insert(format!("disk:vol:{}:\\:watch", drive), 1.0);
625 let is_system_drive = std::env::var("SystemDrive")
633 .ok()
634 .and_then(|s| s.chars().next())
635 .map(|c| c.to_ascii_uppercase() == drive)
636 .unwrap_or(false);
637 _values.insert(
638 format!("disk:vol:{}:\\:is_system_drive", drive),
639 if is_system_drive { 1.0 } else { 0.0 },
640 );
641 }
642 }
643}
644
645#[cfg(all(test, target_os = "windows"))]
646mod handle_anomaly_tests {
647 use super::*;
648
649 fn hsample(pid: u32, name: &str, handles: u32) -> HandleSample {
650 HandleSample { pid, name: name.to_string(), handles }
651 }
652
653 #[test]
654 fn healthy_baselines_trip_nothing() {
655 let samples = vec![
656 hsample(1, "agentmux-srv.exe", 950),
657 hsample(2, "svchost.exe", 3_000),
658 hsample(3, "chrome.exe", 11_000),
659 ];
660 assert!(classify_handle_anomalies(&samples).is_empty());
661 }
662
663 #[test]
664 fn agentmux_process_trips_at_the_lower_threshold() {
665 let samples = vec![
669 hsample(1, "agentmux-srv-0.54.10-windows.x64.exe", HANDLE_WARN_AGENTMUX),
670 hsample(2, "chrome.exe", HANDLE_WARN_AGENTMUX + 5_000),
671 ];
672 let anomalies = classify_handle_anomalies(&samples);
673 assert_eq!(anomalies.len(), 1);
674 assert!(anomalies[0].is_agentmux);
675 assert_eq!(anomalies[0].pid, 1);
676 }
677
678 #[test]
679 fn external_process_trips_only_at_the_higher_threshold() {
680 let samples = vec![hsample(9, "svchost.exe", 85_000)];
682 let anomalies = classify_handle_anomalies(&samples);
683 assert_eq!(anomalies.len(), 1);
684 assert!(!anomalies[0].is_agentmux);
685 }
686
687 #[test]
688 fn anomalies_are_sorted_worst_first() {
689 let samples = vec![
690 hsample(1, "agentmux-srv.exe", 25_000),
691 hsample(2, "svchost.exe", 85_000),
692 ];
693 let anomalies = classify_handle_anomalies(&samples);
694 assert_eq!(anomalies[0].pid, 2);
695 assert_eq!(anomalies[1].pid, 1);
696 }
697
698 #[test]
699 fn own_process_handle_count_is_queryable_and_nonzero() {
700 let count = process_handle_count(std::process::id());
703 assert!(count.is_some_and(|c| c > 0), "got {count:?}");
704 }
705
706 #[test]
721 fn refresh_processes_specifics_does_not_leak_a_handle_per_call() {
722 let pid = std::process::id();
723 let mut sys = sysinfo::System::new();
724 for _ in 0..5 {
728 sys.refresh_processes_specifics(
729 ProcessesToUpdate::All,
730 false,
731 ProcessRefreshKind::nothing(),
732 );
733 }
734 let before = process_handle_count(pid).expect("own handle count must be queryable");
735
736 const CALLS: u32 = 500;
737 for _ in 0..CALLS {
738 sys.refresh_processes_specifics(
739 ProcessesToUpdate::All,
740 false,
741 ProcessRefreshKind::nothing(),
742 );
743 }
744 let after = process_handle_count(pid).expect("own handle count must be queryable");
745
746 let grew_by = after.saturating_sub(before);
747 assert!(
748 grew_by < CALLS / 2,
749 "handle count grew by {grew_by} over {CALLS} refresh_processes_specifics calls \
750 (before={before}, after={after}) — consistent with a per-call handle leak \
751 (the fixed-in-0.35.0 CreateToolhelp32Snapshot bug leaked exactly 1/call)"
752 );
753 }
754
755 fn anomaly(pid: u32) -> HandleAnomaly {
756 HandleAnomaly { pid, name: format!("proc{pid}.exe"), handles: 99_999, is_agentmux: false }
757 }
758
759 #[test]
760 fn cooldown_warns_immediately_on_first_sight_then_suppresses() {
761 let mut last = HashMap::new();
762 let t0 = Instant::now();
763 let cooldown = Duration::from_secs(600);
764 let anomalies = vec![anomaly(1)];
765
766 assert_eq!(filter_anomalies_for_warn(&anomalies, &mut last, t0, cooldown), vec![0]);
767 let t1 = t0 + Duration::from_secs(30);
769 assert!(filter_anomalies_for_warn(&anomalies, &mut last, t1, cooldown).is_empty());
770 let t2 = t0 + cooldown;
772 assert_eq!(filter_anomalies_for_warn(&anomalies, &mut last, t2, cooldown), vec![0]);
773 }
774
775 #[test]
776 fn cooldown_is_per_pid_not_global() {
777 let mut last = HashMap::new();
778 let t0 = Instant::now();
779 let cooldown = Duration::from_secs(600);
780
781 assert_eq!(filter_anomalies_for_warn(&[anomaly(1)], &mut last, t0, cooldown), vec![0]);
782 let t1 = t0 + Duration::from_secs(30);
785 let both = vec![anomaly(1), anomaly(2)];
786 assert_eq!(filter_anomalies_for_warn(&both, &mut last, t1, cooldown), vec![1]);
787 }
788
789 #[test]
790 fn recovered_pid_recrossing_the_threshold_warns_immediately() {
791 let mut last = HashMap::new();
792 let t0 = Instant::now();
793 let cooldown = Duration::from_secs(600);
794
795 assert_eq!(filter_anomalies_for_warn(&[anomaly(1)], &mut last, t0, cooldown), vec![0]);
796 let t1 = t0 + Duration::from_secs(30);
799 assert!(filter_anomalies_for_warn(&[], &mut last, t1, cooldown).is_empty());
800 assert!(last.is_empty(), "healthy pid's entry must be pruned");
801 let t2 = t0 + Duration::from_secs(60);
804 assert_eq!(filter_anomalies_for_warn(&[anomaly(1)], &mut last, t2, cooldown), vec![0]);
805 }
806}
807
808#[cfg(all(test, target_os = "windows"))]
809mod commit_attribution_tests {
810 use super::*;
811
812 fn sample(pid: u32, name: &str, commit_mb: f64) -> ProcSample {
813 ProcSample { pid, name: name.to_string(), commit_mb }
814 }
815
816 #[test]
817 fn buckets_agentmux_by_name_regardless_of_pane_membership() {
818 let samples = vec![sample(100, "agentmux-cef.exe", 500.0)];
822 let mut pane_pids = HashSet::new();
823 pane_pids.insert(100);
824 let result = classify_commit_attribution(&samples, &pane_pids, 5);
825 assert_eq!(result.agentmux_mb, 500.0);
826 assert_eq!(result.panes_mb, 0.0);
827 }
828
829 #[test]
830 fn buckets_pane_pids_by_membership_not_name() {
831 let samples = vec![sample(200, "node.exe", 300.0), sample(201, "conhost.exe", 10.0)];
834 let mut pane_pids = HashSet::new();
835 pane_pids.insert(200);
836 pane_pids.insert(201);
837 let result = classify_commit_attribution(&samples, &pane_pids, 5);
838 assert_eq!(result.panes_mb, 310.0);
839 assert_eq!(result.agentmux_mb, 0.0);
840 assert!(result.other_top.is_empty());
841 }
842
843 #[test]
844 fn everything_else_falls_to_other_ranked_and_capped() {
845 let samples = vec![
846 sample(1, "chrome.exe", 800.0),
847 sample(2, "Code.exe", 600.0),
848 sample(3, "Docker Desktop.exe", 1200.0),
849 sample(4, "explorer.exe", 150.0),
850 ];
851 let result = classify_commit_attribution(&samples, &HashSet::new(), 2);
852 assert_eq!(result.other_mb, 800.0 + 600.0 + 1200.0 + 150.0);
854 assert_eq!(result.other_top.len(), 2);
856 assert_eq!(result.other_top[0].0, "Docker Desktop.exe");
857 assert_eq!(result.other_top[1].0, "chrome.exe");
858 }
859
860 #[test]
861 fn aggregates_other_by_name_before_ranking_so_a_multi_process_app_is_not_split_below_the_cutoff() {
862 let samples = vec![
869 sample(1, "chrome.exe", 100.0),
870 sample(2, "chrome.exe", 100.0),
871 sample(3, "chrome.exe", 100.0),
872 sample(4, "chrome.exe", 100.0),
873 sample(5, "single_app.exe", 250.0),
874 ];
875 let result = classify_commit_attribution(&samples, &HashSet::new(), 1);
876 assert_eq!(result.other_mb, 650.0);
877 assert_eq!(result.other_top.len(), 1);
878 assert_eq!(result.other_top[0], ("chrome.exe".to_string(), 400.0));
879 }
880
881 #[test]
882 fn name_match_is_case_insensitive() {
883 let samples = vec![sample(1, "AgentMux-Srv.exe", 42.0)];
884 let result = classify_commit_attribution(&samples, &HashSet::new(), 5);
885 assert_eq!(result.agentmux_mb, 42.0);
886 }
887
888 #[test]
889 fn empty_process_list_yields_zeroed_result() {
890 let result = classify_commit_attribution(&[], &HashSet::new(), 5);
891 assert_eq!(result.agentmux_mb, 0.0);
892 assert_eq!(result.panes_mb, 0.0);
893 assert_eq!(result.other_mb, 0.0);
894 assert!(result.other_top.is_empty());
895 }
896
897 #[test]
905 fn log_memory_attribution_runs_against_the_real_process_table_without_panicking() {
906 let mut sys = sysinfo::System::new_all();
907 log_memory_attribution(&mut sys, 0.0, 0.0); assert!(
909 sys.processes().len() > 1,
910 "expected the real process table to have picked up more than just this test binary"
911 );
912 }
913}
914
915struct NetState {
917 prev_sent: u64,
918 prev_recv: u64,
919 prev_time: Option<Instant>,
920}
921
922impl NetState {
923 fn new() -> Self {
924 Self {
925 prev_sent: 0,
926 prev_recv: 0,
927 prev_time: None,
928 }
929 }
930
931 fn get_net_data(&mut self, networks: &Networks, values: &mut HashMap<String, f64>) {
933 let mut total_sent: u64 = 0;
935 let mut total_recv: u64 = 0;
936 for (_name, data) in networks.iter() {
937 total_sent += data.total_transmitted();
938 total_recv += data.total_received();
939 }
940
941 let now = Instant::now();
942 if let Some(prev_time) = self.prev_time {
943 let elapsed = now.duration_since(prev_time).as_secs_f64();
944 if elapsed > 0.0 {
945 let sent_rate = (total_sent.saturating_sub(self.prev_sent)) as f64 / elapsed / BYTES_PER_MB;
946 let recv_rate = (total_recv.saturating_sub(self.prev_recv)) as f64 / elapsed / BYTES_PER_MB;
947 values.insert("net:bytessent".to_string(), sent_rate);
948 values.insert("net:bytesrecv".to_string(), recv_rate);
949 values.insert("net:bytestotal".to_string(), sent_rate + recv_rate);
950 }
951 }
952
953 self.prev_sent = total_sent;
954 self.prev_recv = total_recv;
955 self.prev_time = Some(now);
956 }
957}
958
959fn get_disk_data(disks: &Disks, elapsed_secs: f64, values: &mut HashMap<String, f64>) {
963 let mut seen_mounts: HashSet<&std::path::Path> = HashSet::new();
972 for disk in disks.list() {
973 let mount = disk.mount_point();
974 if !seen_mounts.insert(mount) {
975 continue;
976 }
977 let total = disk.total_space();
978 if total == 0 {
979 continue;
980 }
981 let mount_str = mount.to_string_lossy();
982 values.insert(
983 format!("disk:vol:{}:free_gb", mount_str),
984 disk.available_space() as f64 / BYTES_PER_GB,
985 );
986 values.insert(
987 format!("disk:vol:{}:total_gb", mount_str),
988 total as f64 / BYTES_PER_GB,
989 );
990 }
991
992 if elapsed_secs <= 0.0 {
993 return;
994 }
995 let (total_read, total_write) = disks.list().iter().fold((0u64, 0u64), |(r, w), disk| {
996 let u = disk.usage();
997 (r + u.read_bytes, w + u.written_bytes)
998 });
999 let read_rate = total_read as f64 / elapsed_secs / BYTES_PER_MB;
1000 let write_rate = total_write as f64 / elapsed_secs / BYTES_PER_MB;
1001 values.insert("disk:read".to_string(), read_rate);
1002 values.insert("disk:write".to_string(), write_rate);
1003 values.insert("disk:total".to_string(), read_rate + write_rate);
1004}
1005
1006#[cfg(not(target_os = "windows"))]
1014fn pick_watch_mount<'a>(mounts: &[&'a std::path::Path], target: &std::path::Path) -> Option<&'a std::path::Path> {
1015 mounts
1016 .iter()
1017 .filter(|m| target.starts_with(m))
1018 .max_by_key(|m| m.as_os_str().len())
1019 .copied()
1020}
1021
1022#[cfg(not(target_os = "windows"))]
1032fn get_watch_volume_data(disks: &Disks, values: &mut HashMap<String, f64>) {
1033 let data_dir = crate::backend::base::get_mux_data_dir();
1034 let mounts: Vec<&std::path::Path> = disks.list().iter().map(|d| d.mount_point()).collect();
1035 let root = std::path::Path::new("/");
1036 let watch = pick_watch_mount(&mounts, &data_dir).unwrap_or(root);
1037
1038 write_watch_summary(watch, watch == root, values);
1039}
1040
1041#[cfg(not(target_os = "windows"))]
1052fn write_watch_summary(mount: &std::path::Path, is_system_drive: bool, values: &mut HashMap<String, f64>) {
1053 let mount_str = mount.to_string_lossy();
1054 let (Some(free_gb), Some(total_gb)) = (
1055 values.get(&format!("disk:vol:{mount_str}:free_gb")).copied(),
1056 values.get(&format!("disk:vol:{mount_str}:total_gb")).copied(),
1057 ) else {
1058 return;
1059 };
1060 if total_gb <= 0.0 {
1061 return;
1062 }
1063
1064 values.insert("disk:watch:free_gb".to_string(), free_gb);
1065 values.insert("disk:watch:total_gb".to_string(), total_gb);
1066 values.insert("disk:watch:free_pct".to_string(), (free_gb / total_gb) * 100.0);
1067 values.insert(format!("disk:vol:{mount_str}:watch"), 1.0);
1068 values.insert(
1069 format!("disk:vol:{mount_str}:is_system_drive"),
1070 if is_system_drive { 1.0 } else { 0.0 },
1071 );
1072}
1073
1074fn get_interval_secs(config_watcher: &ConfigState) -> f64 {
1076 let val = config_watcher.get_settings().telemetry_interval;
1077 if val <= 0.0 {
1078 return DEFAULT_INTERVAL_SECS;
1079 }
1080 val.clamp(MIN_INTERVAL_SECS, MAX_INTERVAL_SECS)
1081}
1082
1083pub async fn run_sysinfo_loop(
1087 broker: Arc<Broker>,
1088 config_watcher: Arc<ConfigState>,
1089 conn_name: String,
1090 panes: crate::backend::sampler::panes::PaneSampler,
1091) {
1092 let mut sys = sysinfo::System::new_all();
1093 let mut networks = Networks::new_with_refreshed_list();
1094 let mut net_state = NetState::new();
1095 let mut disks = Disks::new_with_refreshed_list();
1096 let mut last_disk_full_refresh = Instant::now();
1097 let mut last_tick = Instant::now();
1098 #[cfg(target_os = "windows")]
1103 let mut last_attribution = Instant::now()
1104 .checked_sub(ATTRIBUTION_INTERVAL)
1105 .unwrap_or_else(Instant::now);
1106 #[cfg(target_os = "windows")]
1107 let mut last_urgent_attribution: Option<Instant> = None;
1108
1109 let mut peaks = crate::backend::blockstats_detail::Peaks::default();
1111
1112 let epoch = Instant::now();
1119 let since_epoch = |t: Instant| t.duration_since(epoch).as_millis() as u64;
1120 let min_interval_ms = (MIN_INTERVAL_SECS * 1000.0) as u64;
1121 let mut sched = Scheduler::new(
1122 vec![
1123 SourceSpec { id: SourceId::Machine, min_interval_ms, budget_ms: NEVER_STRETCHED, needs: &[] },
1127 SourceSpec { id: SourceId::Panes, min_interval_ms, budget_ms: 400, needs: &[] },
1128 ],
1129 SCHEDULER_COALESCE_MS,
1130 );
1131 let mut watched = false;
1132 let mut current_interval = get_interval_secs(&config_watcher);
1133 sched.lease(HOLDER_TELEMETRY, SourceId::Machine, (current_interval * 1000.0) as u64, None, 0);
1134 sched.lease(HOLDER_JANITOR, SourceId::Panes, PANE_JANITOR_MS, None, 0);
1135 sched.finished(SourceId::Machine, 0, 0);
1137 sched.finished(SourceId::Panes, 0, 0);
1138
1139 tracing::info!("sysinfo loop started for conn:{}", conn_name);
1140
1141 loop {
1142 let now_ms = since_epoch(Instant::now());
1143 let new_interval = get_interval_secs(&config_watcher);
1144 if (new_interval - current_interval).abs() > 0.001 {
1145 current_interval = new_interval;
1146 tracing::info!("sysinfo interval changed to {}s", current_interval);
1147 }
1148 let interval_ms = (current_interval * 1000.0) as u64;
1149 sched.lease(HOLDER_TELEMETRY, SourceId::Machine, interval_ms, None, now_ms);
1150 let has_viewers = broker.has_subscribers(crate::backend::mps::EVENT_BLOCK_STATS);
1151 if has_viewers {
1152 sched.lease(HOLDER_BADGES, SourceId::Panes, interval_ms, None, now_ms);
1153 } else {
1154 sched.release_source(HOLDER_BADGES, SourceId::Panes, now_ms);
1155 }
1156 if has_viewers != watched {
1157 watched = has_viewers;
1158 tracing::debug!(watched, "sysinfo: pane stats {}", if watched { "have a viewer" } else { "have no viewer" });
1159 }
1160 let wake = sched.next_wakeup(now_ms).unwrap_or(now_ms + interval_ms);
1161 if wake > now_ms {
1162 tokio::time::sleep_until(tokio::time::Instant::from_std(epoch + Duration::from_millis(wake))).await;
1163 }
1164 let due = sched.due(since_epoch(Instant::now()));
1165
1166 if due.contains(&SourceId::Machine) {
1167 let run_started = Instant::now();
1168 let mut pagefile_values = HashMap::new();
1180 tokio::task::block_in_place(|| {
1181 sys.refresh_cpu_usage();
1182 sys.refresh_memory();
1183 networks.refresh(true);
1184 if last_disk_full_refresh.elapsed() >= DISK_FULL_REFRESH {
1185 disks.refresh(true);
1186 last_disk_full_refresh = Instant::now();
1187 } else {
1188 for disk in disks.list_mut() {
1189 disk.refresh_specifics(DiskRefreshKind::nothing().with_io_usage());
1190 }
1191 }
1192 get_pagefile_volume_data(&mut pagefile_values);
1193 });
1194
1195 let now_instant = Instant::now();
1196 let elapsed_secs = now_instant.duration_since(last_tick).as_secs_f64();
1197 last_tick = now_instant;
1198
1199 let mut values = pagefile_values;
1200 get_cpu_data(&sys, &mut values);
1201 get_mem_data(&sys, &mut values);
1202 get_commit_data(&mut values);
1203 net_state.get_net_data(&networks, &mut values);
1204 get_disk_data(&disks, elapsed_secs, &mut values);
1205 #[cfg(not(target_os = "windows"))]
1209 get_watch_volume_data(&disks, &mut values);
1210
1211 #[cfg(target_os = "windows")]
1222 {
1223 let commit_avail_gb = match (values.get("mem:commit:total"), values.get("mem:commit:used")) {
1224 (Some(total), Some(used)) => Some(total - used),
1225 _ => None,
1226 };
1227 let due_periodic = now_instant.duration_since(last_attribution) >= ATTRIBUTION_INTERVAL;
1228 let due_urgent = commit_avail_gb.is_some_and(|gb| gb < ATTRIBUTION_URGENT_THRESHOLD_GB)
1229 && last_urgent_attribution
1230 .is_none_or(|t| now_instant.duration_since(t) >= ATTRIBUTION_URGENT_COOLDOWN);
1231 if due_periodic || due_urgent {
1232 let commit_used_gb = *values.get("mem:commit:used").unwrap_or(&0.0);
1233 let commit_total_gb = *values.get("mem:commit:total").unwrap_or(&0.0);
1234 tokio::task::block_in_place(|| {
1235 log_memory_attribution(&mut sys, commit_used_gb, commit_total_gb)
1236 });
1237 last_attribution = now_instant;
1238 if due_urgent {
1239 last_urgent_attribution = Some(now_instant);
1240 }
1241 }
1242 }
1243
1244 let now = agentmux_common::time::now_ms();
1245
1246 let ts_data = TimeSeriesData { ts: now, values, uptime_secs: Some(uptime_secs()) };
1247
1248 broker.publish_event::<crate::backend::mps_events::SysInfo>(&ts_data, vec![conn_name.clone()], PERSIST_COUNT);
1249 sched.finished(SourceId::Machine, since_epoch(run_started), run_started.elapsed().as_millis() as u64);
1250 }
1251
1252 if due.contains(&SourceId::Panes) {
1253 let run_started = Instant::now();
1254 let now = agentmux_common::time::now_ms();
1255 let registered = pidregistry::get_all();
1261 let asked = Instant::now();
1262 match tokio::task::block_in_place(|| panes()) {
1263 Ok(stats) => {
1264 peaks.retain(|id| stats.iter().any(|s| s.block_id == id));
1265 for s in &stats {
1266 let mut values = HashMap::new();
1267 if let Some(cpu) = s.cpu_pct {
1268 values.insert("cpu".to_string(), cpu);
1269 }
1270 values.insert("mem".to_string(), s.mem as f64);
1271 values.insert("pids".to_string(), f64::from(s.pids));
1272 let peak = peaks.observe(&s.block_id, s.shell, s.mem);
1273 values.insert("mem:peak".to_string(), peak as f64);
1274 let block_ts = TimeSeriesData { ts: now, values, uptime_secs: None };
1276 broker.publish_event::<crate::backend::mps_events::BlockStats>(
1277 &block_ts,
1278 vec![format!("block:{}", s.block_id)],
1279 0,
1280 );
1281 }
1282 }
1283 Err(e) => tracing::debug!(error = %e, "sysinfo: pane stats sample failed"),
1284 }
1285 if let Some(live) = crate::backend::tower_sampler::Tower::global().live_pids_since(asked) {
1291 for (block_id, pid) in registered {
1292 if !live.contains(&pid) {
1293 pidregistry::unregister(&block_id);
1294 tracing::warn!(
1295 block_id = %block_id,
1296 "sysinfo: evicted dead root PID — process exited without normal cleanup"
1297 );
1298 }
1299 }
1300 }
1301 sched.finished(SourceId::Panes, since_epoch(run_started), run_started.elapsed().as_millis() as u64);
1302 }
1303 }
1304}
1305
1306#[cfg(test)]
1307mod uptime_tests {
1308 use super::*;
1309
1310 #[test]
1318 fn uptime_is_monotonic_and_the_start_marker_is_idempotent() {
1319 mark_process_start();
1320 let first = uptime_secs();
1321 mark_process_start();
1322 let second = uptime_secs();
1323 assert!(second >= first, "uptime went backwards: {second} < {first}");
1324 }
1325
1326 #[test]
1330 fn suspend_aware_clock_is_nonzero_and_never_decreases() {
1331 let a = suspend_aware_now_ms();
1332 let b = suspend_aware_now_ms();
1333 assert!(a > 0, "suspend-aware clock returned 0 — clock_gettime failed?");
1334 assert!(b >= a, "suspend-aware clock went backwards: {b} < {a}");
1335 assert!(a < 10 * 365 * 24 * 60 * 60 * 1_000, "implausible ms reading: {a}");
1338 }
1339
1340 #[test]
1344 fn uptime_secs_is_emitted_when_present_and_omitted_when_absent() {
1345 let with = TimeSeriesData { ts: 5, values: HashMap::new(), uptime_secs: Some(42) };
1346 let v = serde_json::to_value(&with).expect("serialize");
1347 assert_eq!(v["uptime_secs"], serde_json::json!(42));
1348
1349 let without = TimeSeriesData { ts: 5, values: HashMap::new(), uptime_secs: None };
1350 let v = serde_json::to_value(&without).expect("serialize");
1351 assert!(
1352 v.get("uptime_secs").is_none(),
1353 "block-stats/CPU-stream payload shape must be byte-for-byte unchanged"
1354 );
1355 }
1356
1357 #[test]
1360 fn payload_without_uptime_still_deserializes() {
1361 let json = serde_json::json!({ "ts": 7, "values": { "cpu": 1.5 } });
1362 let parsed: TimeSeriesData = serde_json::from_value(json).expect("deserialize");
1363 assert_eq!(parsed.ts, 7);
1364 assert_eq!(parsed.uptime_secs, None);
1365 }
1366}
1367
1368#[cfg(all(test, not(target_os = "windows")))]
1369mod watch_volume_tests {
1370 use super::*;
1371 use std::path::Path;
1372
1373 #[test]
1377 fn longest_prefix_wins_over_root() {
1378 let mounts = [Path::new("/"), Path::new("/home"), Path::new("/boot")];
1379 let got = pick_watch_mount(&mounts, Path::new("/home/yas/.agentmux"));
1380 assert_eq!(got, Some(Path::new("/home")));
1381 }
1382
1383 #[test]
1384 fn falls_back_to_root_when_nothing_more_specific_matches() {
1385 let mounts = [Path::new("/"), Path::new("/boot")];
1386 let got = pick_watch_mount(&mounts, Path::new("/home/yas/.agentmux"));
1387 assert_eq!(got, Some(Path::new("/")));
1388 }
1389
1390 #[test]
1391 fn exact_mount_matches_itself() {
1392 let mounts = [Path::new("/"), Path::new("/data")];
1393 assert_eq!(pick_watch_mount(&mounts, Path::new("/data")), Some(Path::new("/data")));
1394 }
1395
1396 #[test]
1399 fn sibling_mount_is_not_a_prefix() {
1400 let mounts = [Path::new("/home2")];
1401 assert_eq!(pick_watch_mount(&mounts, Path::new("/home/yas")), None);
1402 }
1403
1404 #[test]
1405 fn empty_mount_list_yields_none() {
1406 assert_eq!(pick_watch_mount(&[], Path::new("/home/yas")), None);
1407 }
1408
1409 #[test]
1413 fn partial_volume_emits_no_summary() {
1414 let mut values: HashMap<String, f64> = HashMap::new();
1415 values.insert("disk:vol:/:total_gb".to_string(), 100.0);
1417 write_watch_summary(Path::new("/"), true, &mut values);
1418 assert!(!values.keys().any(|k| k.starts_with("disk:watch:")));
1419 }
1420
1421 #[test]
1422 fn zero_capacity_volume_emits_no_summary() {
1423 let mut values: HashMap<String, f64> = HashMap::new();
1424 values.insert("disk:vol:/:free_gb".to_string(), 0.0);
1425 values.insert("disk:vol:/:total_gb".to_string(), 0.0);
1426 write_watch_summary(Path::new("/"), true, &mut values);
1427 assert!(!values.keys().any(|k| k.starts_with("disk:watch:")));
1428 }
1429
1430 #[test]
1433 fn summary_matches_the_per_volume_figures() {
1434 let mut values: HashMap<String, f64> = HashMap::new();
1435 values.insert("disk:vol:/home:free_gb".to_string(), 35.0);
1436 values.insert("disk:vol:/home:total_gb".to_string(), 350.0);
1437 write_watch_summary(Path::new("/home"), false, &mut values);
1438
1439 assert_eq!(values["disk:watch:free_gb"], 35.0);
1440 assert_eq!(values["disk:watch:total_gb"], 350.0);
1441 assert!((values["disk:watch:free_pct"] - 10.0).abs() < 1e-9);
1442 assert_eq!(values["disk:vol:/home:watch"], 1.0);
1443 assert_eq!(values["disk:vol:/home:is_system_drive"], 0.0);
1444 }
1445
1446 #[test]
1447 fn root_watch_volume_is_marked_as_the_system_drive() {
1448 let mut values: HashMap<String, f64> = HashMap::new();
1449 values.insert("disk:vol:/:free_gb".to_string(), 1.9);
1450 values.insert("disk:vol:/:total_gb".to_string(), 345.0);
1451 write_watch_summary(Path::new("/"), true, &mut values);
1452 assert_eq!(values["disk:vol:/:is_system_drive"], 1.0);
1453 assert!(values["disk:watch:free_pct"] < 1.0);
1455 }
1456}