Skip to main content

agentmux_srv/backend/
sysinfo.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Sysinfo data collection loop: collects CPU, memory, and network metrics
5//! and publishes them via the MPS broker. Sampling interval is configurable
6//! via the `telemetry:interval` setting (0.1s–2.0s, default 1.0s).
7
8use 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;
27/// How often the disk list and each volume's capacity/free space are
28/// re-read. Every tick only refreshes I/O counters (the read/write rates need
29/// a delta per tick). A full refresh re-lists every mounted volume and, on
30/// macOS, asks each for its "available for important usage" capacity, which
31/// goes through CacheDelete: with a few DMGs mounted that was thousands of
32/// system calls and log lines a minute, for numbers that move slowly.
33const DISK_FULL_REFRESH: Duration = Duration::from_secs(30);
34
35/// Sources due within this much of each other run in one pass.
36const SCHEDULER_COALESCE_MS: u64 = 50;
37/// How often the panes' processes are scanned with no badge watching: enough to
38/// evict a pane whose shell exited without normal cleanup.
39const PANE_JANITOR_MS: u64 = 30_000;
40/// A budget no run exceeds: the source keeps its interval whatever a run costs.
41const 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// ── Commit (pagefile) attribution ────────────────────────────────────────
47// docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md: a live OOM
48// crash left no forensic trail of WHICH processes were consuming commit —
49// only the system-wide total (`get_commit_data`) was ever logged. This
50// section adds periodic + urgent-on-pressure attribution, bucketing every
51// process on the machine into "agentmux" (this app's own process family —
52// host, srv, launcher, CEF helper processes), "panes" (every registered
53// block's shell process tree — agent CLI subprocesses, terminal shells,
54// etc.; exact PID-tree membership via `pidregistry` + `process_tree`, not
55// name-guessing), and "other" (everything else, top-N by commit kept for
56// visibility — Chrome, VS Code, Docker, Windows itself, ...).
57
58/// Milliseconds from a SUSPEND-AWARE monotonic clock — one that keeps counting
59/// while the machine is asleep, and can never move backwards.
60///
61/// Uptime needs both properties, and no single obvious clock has them:
62///
63/// - Wall clock (`SystemTime`/`chrono::Utc::now()`) counts suspend but moves
64///   backwards on an NTP correction, a manual set, or a VM resume. That was
65///   the original bug: on 0.55.26 the app started while the machine's clock
66///   read 2081-02-05, the clock was later corrected to 2026-08-29 with no
67///   restart, and the status bar rendered `-59:0-14`.
68/// - `std::time::Instant` can't move backwards, but it STOPS while the machine
69///   is suspended (`CLOCK_MONOTONIC` on Linux, `mach_absolute_time` on macOS,
70///   QPC on Windows). Using it would silently under-report by the whole
71///   suspend interval on every laptop lid-close — a regression against the
72///   wall-clock behaviour it replaced (codex P2 on PR #2831).
73///
74/// So each platform's suspend-aware counter is used directly:
75///
76/// | Platform | Source | Counts suspend |
77/// |---|---|---|
78/// | Linux/Android | `CLOCK_BOOTTIME` | yes (`CLOCK_MONOTONIC` does not) |
79/// | macOS/BSD | `CLOCK_MONOTONIC` | yes — Darwin's continues across sleep, unlike `CLOCK_UPTIME_RAW` and unlike the `mach_absolute_time` behind `Instant` |
80/// | Windows | `GetTickCount64` | yes (`QueryUnbiasedInterruptTime` does not) |
81///
82/// All three count from system boot, not process start, so `mark_process_start`
83/// takes a baseline and `uptime_secs` subtracts it.
84#[cfg(windows)]
85fn suspend_aware_now_ms() -> u64 {
86    // SAFETY: no arguments, no pointers, always succeeds.
87    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    // SAFETY: `ts` is a valid, initialized, exclusively-borrowed timespec.
99    if unsafe { libc::clock_gettime(CLOCK, &mut ts) } != 0 {
100        // Reporting 0 makes uptime read 0 — visibly wrong but harmless and
101        // never negative. Preferable to substituting a clock with different
102        // properties partway through the process's life.
103        return 0;
104    }
105    (ts.tv_sec as u64) * 1_000 + (ts.tv_nsec as u64) / 1_000_000
106}
107
108/// Baseline reading of `suspend_aware_now_ms` captured at process start by
109/// `mark_process_start` (`main.rs`).
110static PROCESS_START_MS: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
111
112/// Record this process's start for uptime reporting. Idempotent; the first
113/// call wins, so a stray second call can't restart the clock.
114pub fn mark_process_start() {
115    let _ = PROCESS_START_MS.set(suspend_aware_now_ms());
116}
117
118/// Seconds since `mark_process_start`. Falls back to initializing on first
119/// read, so a caller that never marked startup reports a truthful (if
120/// late-based) 0 rather than a wrong number or a panic. `saturating_sub`
121/// makes a decrease structurally impossible even if a platform counter ever
122/// misbehaved.
123pub 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/// How often to log a full attribution snapshot in the steady state.
129#[cfg(target_os = "windows")]
130const ATTRIBUTION_INTERVAL: Duration = Duration::from_secs(30);
131/// Re-snapshot immediately (bypassing `ATTRIBUTION_INTERVAL`) once available
132/// commit drops below this — a snapshot taken seconds before a crash is far
133/// more useful than one from up to 30s earlier.
134#[cfg(target_os = "windows")]
135const ATTRIBUTION_URGENT_THRESHOLD_GB: f64 = 2.0;
136/// Debounce for the urgent trigger so a sustained low-commit period doesn't
137/// log every single tick.
138#[cfg(target_os = "windows")]
139const ATTRIBUTION_URGENT_COOLDOWN: Duration = Duration::from_secs(10);
140/// How many "other" processes to name individually in the log line.
141#[cfg(target_os = "windows")]
142const ATTRIBUTION_OTHER_TOP_N: usize = 5;
143/// Per-block descendant-PID cap for the attribution walk — deliberately its
144/// OWN, much larger constant, not `process_tree::MAX_PIDS_PER_BLOCK` (64).
145/// That cap is sized for the Sysinfo pane's small per-block CPU/mem display;
146/// reusing it here silently dropped overflow into the "other" bucket
147/// instead of "panes" in exactly the storm scenario this feature exists to
148/// diagnose (a single block's descendant tree exceeding 64 processes) —
149/// reagent P1 on PR #2207. `collect_descendants` gives no truncation signal
150/// beyond "returned exactly `max_pids` results," so that's what
151/// `log_memory_attribution` checks to log a truncation warning rather than
152/// silently under-counting (no silent caps).
153#[cfg(target_os = "windows")]
154const ATTRIBUTION_MAX_PIDS_PER_BLOCK: usize = 4096;
155
156/// One process's classification input — commit charge, not working set:
157/// what actually exhausts the pagefile. (sysinfo's `virtual_memory()` on
158/// Windows is `PROCESS_MEMORY_COUNTERS_EX::PrivateUsage` — a misleading
159/// name from sysinfo's cross-platform API, but the right number: the
160/// process's private/committed bytes.)
161#[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    /// Top `top_n` "other" processes by commit, descending.
174    other_top: Vec<(String, f64)>,
175}
176
177/// Pure classification, factored out for unit testing without a real
178/// process table (mirrors `resolve_pagefile_watch_target` above).
179/// `pane_pids` is the exact PID set `pidregistry`'s block trees computed —
180/// membership, not a name guess, so this bucket can never miscount a
181/// same-named-but-unrelated process. Aggregates "other" by process name
182/// before ranking the top-N — a multi-process app (Chrome, VS Code) would
183/// otherwise get split across many small per-PID entries that individually
184/// miss the cutoff while collectively dominating `other_mb`, hiding the
185/// true top contributor (reagent P1 on PR #2207).
186#[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/// Handle-count anomaly thresholds. Both real incidents this detects were
217/// found manually with Sysinternals Handle after weeks of commit growth
218/// (docs/status/STATUS_PF_COMMIT_GROWTH_INVESTIGATION_2026_07_24.md §16:
219/// Audiosrv at ~85K handles ≈ 18GB of leaked pagefile-backed Sections;
220/// docs/status/STATUS_SRV_SECTION_HANDLE_LEAK_2026_08_08.md: agentmux-srv
221/// itself at ~215K, 99.5% Sections). A leaking process crosses these within
222/// hours of a healthy baseline (both incidents' healthy baselines were
223/// under 1K handles), while legitimately handle-heavy processes
224/// (System/svchost/browsers) sit well below the external threshold —
225/// 24,797 was the highest non-leaking count observed on the 08-08
226/// machine snapshot.
227#[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/// Minimum gap between repeat WARNs for the SAME still-anomalous PID. The
234/// first live run of this detector (2026-08-09) re-warned every 30s
235/// attribution cycle — and more under the urgent-on-pressure path — for
236/// two persisting Vite-process anomalies, drowning the log. One warn per
237/// PID per this window keeps the signal (a persisting anomaly still
238/// re-surfaces regularly, and the info line's `handles_top` carries the
239/// count every cycle regardless) without the spam. A PID that drops below
240/// threshold and re-crosses later warns immediately again (its entry is
241/// pruned while healthy).
242#[cfg(target_os = "windows")]
243const HANDLE_WARN_COOLDOWN: Duration = Duration::from_secs(600);
244
245/// One process's handle-count sample for anomaly classification.
246#[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    /// True → one of our own processes (leak in AgentMux code, actionable
259    /// by us); false → an external process (Audiosrv-class OS leak — the
260    /// remediation is restarting *that* service, not AgentMux).
261    is_agentmux: bool,
262}
263
264/// Pure anomaly classification, factored out for unit testing like
265/// `classify_commit_attribution` above. AgentMux's own processes get the
266/// lower threshold (we know their healthy baseline is well under 1K); any
267/// other process only trips the higher one.
268#[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/// Pure cooldown gate for anomaly WARNs, factored out for unit testing.
288/// Returns the indices (into `anomalies`) that should warn THIS cycle, and
289/// updates `last_warned` accordingly. Entries for PIDs that are no longer
290/// anomalous are pruned first — both so the map can't grow unbounded across
291/// process churn, and so a process that recovers and later re-crosses the
292/// threshold warns immediately rather than being silently inside a stale
293/// cooldown window.
294#[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/// Process-lifetime state for the cooldown above. A plain static rather
319/// than threading it through the loop: `log_memory_attribution` is only
320/// ever called from the single sysinfo loop task, so contention is nil.
321#[cfg(target_os = "windows")]
322static LAST_HANDLE_WARN: std::sync::OnceLock<std::sync::Mutex<HashMap<u32, Instant>>> =
323    std::sync::OnceLock::new();
324
325/// Open-query-close a process's total handle count. Returns `None` for
326/// PIDs we can't open (protected/system processes) — expected, skipped
327/// silently. The handle opened here is closed unconditionally on both the
328/// success and failure path; leaking handles from the handle-leak detector
329/// would be a bit much.
330#[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/// Full-system commit snapshot: refresh every process (heavier than the
349/// per-block passes above, but only ever called on the attribution
350/// cadence — every 30s in steady state, or debounced-urgent under
351/// pressure), classify, and log one line. Independent of the per-tick
352/// block-stats pass above — it does its own PID-tree collection so it
353/// can't be affected by that pass's ordering.
354///
355/// `commit_used_gb`/`commit_total_gb` come from the caller's already-computed
356/// `get_commit_data` values rather than re-querying `GlobalMemoryStatusEx`
357/// here — this function is on the same tick as that call.
358#[cfg(target_os = "windows")]
359fn log_memory_attribution(sys: &mut sysinfo::System, commit_used_gb: f64, commit_total_gb: f64) {
360    // Only name()/parent()/virtual_memory() are read below — parent() and
361    // name() are always populated regardless of refresh kind, so only
362    // memory needs to be requested. The per-pane pass a few lines below
363    // (targeted `ProcessesToUpdate::Some`) uses the same restraint; this is
364    // the whole-machine equivalent, so it's the one call where skipping
365    // CPU deltas/disk I/O/exe/cmdline/environ actually matters.
366    sys.refresh_processes_specifics(
367        ProcessesToUpdate::All,
368        true, // remove dead processes — keep this bounded to what's live
369        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    // The two diagnostics both prior pagefile-exhaustion incidents needed
410    // (and had to gather manually with Sysinternals — see the doc refs on
411    // HANDLE_WARN_AGENTMUX above): the *unattributed* commit gap, and
412    // per-process handle counts. Process-private buckets alone are blind to
413    // exactly the leak class that caused both incidents: pagefile-backed
414    // Section objects held via leaked handles are charged to system commit
415    // but to no process's private bytes, so `agentmux_mb + panes_mb +
416    // other_mb` stays flat while `commit_used_gb` climbs — the gap IS the
417    // signal. Handle counts then attribute the gap to the owning process
418    // (which the commit numbers can't), the way the manual handle.exe sweep
419    // did both times.
420    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    // One OpenProcess/GetProcessHandleCount/CloseHandle triplet per live
424    // process, on the same 30s cadence as the rest of this function —
425    // microseconds each, no handles retained (see process_handle_count).
426    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    // Subsystem counters alongside the memory numbers, same log line/target
479    // so `muxlog srv grep mem_attribution` correlates them for free — added
480    // while investigating a newly-reported leak (2026-09-20) that turned out
481    // not to match either previously-fixed Section-handle mechanism
482    // (#2666/#2722, both already in this build): a block/PID count that
483    // climbs without bound, or reaper candidates that never resolve to
484    // `reaped`, point at these two newer subsystems specifically rather than
485    // requiring a repeat of the manual handle64 archaeology those fixes needed.
486    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
512/// Collect CPU usage (total + per-core).
513fn 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    // Total CPU usage (average across all cores)
519    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    // Per-core usage
522    for (idx, cpu) in cpus.iter().enumerate() {
523        values.insert(format!("cpu:{}", idx), cpu.cpu_usage() as f64);
524    }
525}
526
527/// Collect memory metrics (in GB).
528fn 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
539/// Read the Windows commit budget via `GlobalMemoryStatusEx`.
540/// Emits `mem:commit:used` and `mem:commit:total` (in GB).
541/// No-op on non-Windows — keys are simply absent from the payload.
542fn 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
557/// Available system commit headroom in GB (Windows: `GlobalMemoryStatusEx`
558/// → `ullAvailPageFile`). Returns `None` where there's no cheap commit figure
559/// (non-Windows) or the read fails — callers treat `None` as "no limit / admit".
560/// This is the admission-control signal for Pillar 3 (commit-aware agent spawn):
561/// see `agents::runner::admit_spawn` and `SPEC_WIN10_PAGEFILE_OOM_CRASH`.
562pub 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
575// ── SPEC_WIN10_PAGEFILE_OOM_CRASH_2026_06_29 §5.2 P0 ──────────────────────────────────
576// "Track free disk on the pagefile volume, not just avail_page_gb. Warn when
577// free disk < ~15% and page file is system-managed." The existing commit
578// gauge above only sees the SYMPTOM (commit near limit); it is blind to the
579// CAUSE this spec found: a system-managed page file wants to grow toward
580// min(3×RAM, ⅛ volume) but silently cannot if the volume it lives on doesn't
581// have the free space, pinning the commit ceiling well below what every
582// other gauge assumes. This makes the cause itself visible.
583//
584// The registry-parsing + disk-free logic itself now lives in
585// `agentmux_common::pagefile` (extracted by
586// SPEC_RAM_PAGEFILE_PRESSURE_SPLIT_2026_08_07 §4) so `agentmux-cef`'s
587// low-memory banner can consume the identical implementation instead of a
588// second, independently-drifting copy — this file just wires it into the
589// StatusBar telemetry payload.
590
591/// Windows watch volume: the drive backing the page file. Emits the
592/// platform-neutral `disk:watch:free_gb` / `:total_gb` / `:free_pct`, plus
593/// `disk:pagefile_system_managed` (1.0/0.0), which stays Windows-named
594/// because it is a page-file fact and its ABSENCE is what tells the status
595/// bar it is not on Windows (see the colour tri-state in
596/// SPEC_STATUSBAR_DISK_PILL_CROSS_PLATFORM_2026_09_18 §3.3).
597///
598/// No-op (keys absent) on non-Windows or any read failure — matches
599/// `get_commit_data`'s fail-open convention. The Unix half of the same
600/// concept is `get_watch_volume_data` below.
601fn 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            // Marks which disk:vol:<mount>:* volume (get_disk_data) the
619            // pagefile-volume gauge above refers to, so the status bar can
620            // name the drive ("C:") in its Disk readout without a separate
621            // string channel — the values map is f64-only, so the letter
622            // rides in the key. Mount format matches sysinfo's Windows
623            // mount_point() ("C:\").
624            _values.insert(format!("disk:vol:{}:\\:watch", drive), 1.0);
625            // Whether the watched (page-file) volume is also the OS's own
626            // system drive -- usually true, but a custom PagingFiles entry
627            // can point the page file at a different volume than
628            // %SystemDrive% (e.g. `E:\pagefile.sys 0 0` with SystemDrive=C).
629            // Lets the status-bar tooltip say "system drive (C:)" only when
630            // that's actually true, rather than mislabeling a page-file-only
631            // volume as the system drive (reagent finding on #2479).
632            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        // The real 08-08 incident profile: an agentmux process far above
666        // its healthy baseline but an external process (chrome-scale) that
667        // is high-but-normal must not be flagged alongside it.
668        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        // The real 07-24 incident profile: Audiosrv's svchost at ~85K.
681        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        // Live smoke test of the winapi wrapper against this test process
701        // itself — a running process always has at least a few handles.
702        let count = process_handle_count(std::process::id());
703        assert!(count.is_some_and(|c| c > 0), "got {count:?}");
704    }
705
706    /// Regression test for the `sysinfo` v0.34.2 `CreateToolhelp32Snapshot`
707    /// handle leak (docs/status/STATUS_SRV_SECTION_HANDLE_LEAK_2026_08_08.md,
708    /// docs/status/STATUS_SRV_SECTION_HANDLE_LEAK_LIVE_RECURRENCE_2026_08_19.md).
709    /// That version's Windows `refresh_processes_specifics` never closed the
710    /// handle it got from `CreateToolhelp32Snapshot`, leaking one `Section`
711    /// object (kernel-charged, pagefile-backed) per call — invisible to this
712    /// process's own working-set/private-bytes, only visible as a climbing
713    /// `GetProcessHandleCount`. Repeats the exact call pattern
714    /// `run_sysinfo_loop` makes per tick (`ProcessesToUpdate::All`, the light
715    /// refresh kind used for the parent-link pass) many times and asserts
716    /// this process's own handle count does NOT grow linearly with call
717    /// count — a fixed `sysinfo` leaks ~0 handles/call; the broken 0.34.2
718    /// leaked exactly 1/call, so 500 calls would have shown +500, not "a
719    /// small bounded amount from unrelated one-time OS/runtime activity."
720    #[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        // Warm up: first call(s) can allocate one-time bookkeeping (e.g. the
725        // process list's initial capacity) that isn't part of the per-call
726        // leak this test is checking for.
727        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        // Same anomaly, next 30s cycle — suppressed.
768        let t1 = t0 + Duration::from_secs(30);
769        assert!(filter_anomalies_for_warn(&anomalies, &mut last, t1, cooldown).is_empty());
770        // Past the cooldown — warns again.
771        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        // A DIFFERENT pid appearing 30s later warns immediately, even though
783        // pid 1 is mid-cooldown.
784        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        // pid 1 drops below threshold (absent from anomalies) — its entry is
797        // pruned...
798        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        // ...so re-crossing 30s later (well inside what would have been the
802        // old cooldown window) warns immediately.
803        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        // Name match wins even if (hypothetically) a PID also showed up in
819        // a pane's descendant tree — AgentMux's own processes are never
820        // "pane" work, no matter how the tree walk found them.
821        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        // A pane's process tree (e.g. node.exe running Claude Code) is
832        // classified by exact PID membership, not by guessing its name.
833        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        // Total is preserved even though the printed list is capped.
853        assert_eq!(result.other_mb, 800.0 + 600.0 + 1200.0 + 150.0);
854        // Only the top 2 (by commit) are kept for the log line, descending.
855        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        // A multi-process app (Chrome, VS Code) spread across many small
863        // per-PID entries must still rank by its TOTAL commit, not have each
864        // PID compete individually against single-process apps — otherwise
865        // the true top contributor can be missing from other_top even
866        // though other_mb (the sum) correctly reflects it. reagent P1 on
867        // PR #2207.
868        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    /// Runs the real Win32 process-enumeration path against this machine's
898    /// actual process table — the synthetic-data tests above cover
899    /// classification logic but can't catch a bad Win32 API call, an
900    /// access-denied edge case, or a panic on a real process's field
901    /// values. This test's own process is guaranteed to exist in the table
902    /// with a real name and non-zero commit, so a classification bucket is
903    /// exercised end-to-end for real.
904    #[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); // must not panic
908        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
915/// Network I/O tracking state for rate calculations.
916struct 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    /// Collect network I/O rates (in MB/s).
932    fn get_net_data(&mut self, networks: &Networks, values: &mut HashMap<String, f64>) {
933        // Sum across all interfaces
934        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
959/// Collect disk I/O rates (in MB/s).
960/// sysinfo Disk::usage() returns deltas (bytes since last refresh) so we
961/// divide by elapsed time to get rates.
962fn get_disk_data(disks: &Disks, elapsed_secs: f64, values: &mut HashMap<String, f64>) {
963    // Per-volume capacity/free, keyed by mount point:
964    //   disk:vol:<mount>:free_gb / disk:vol:<mount>:total_gb
965    // (e.g. `disk:vol:C:\:free_gb` — the mount's own colons/backslashes are
966    // fine inside the key; consumers anchor on the `disk:vol:` prefix and the
967    // known suffix). Feeds the status bar's Disk popover (per-drive free
968    // space). Deduped by mount point — sysinfo can list one volume once per
969    // physical disk it spans — and zero-capacity entries (empty card
970    // readers, unformatted media) are skipped rather than shown as "0G".
971    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/// Pick the mount whose path is the longest prefix of `target`.
1007///
1008/// Pure, and separated from `Disks` so it is testable without a real
1009/// filesystem. Longest-prefix rather than first-match because several mounts
1010/// legitimately prefix one path (`/` and `/home` both prefix
1011/// `/home/yas/.agentmux`) and the most specific one is the filesystem whose
1012/// free space actually constrains a write there.
1013#[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/// Unix watch volume: the mount backing AgentMux's data dir — where the
1023/// store, logs and packaged builds land, so it is the volume whose filling up
1024/// actually breaks this app.
1025///
1026/// Emits the same `disk:watch:*` summary and `disk:vol:<mount>:watch` /
1027/// `:is_system_drive` markers the Windows path emits, so the status bar is
1028/// platform-agnostic. Costs no syscall: it reads the `Disks` list already
1029/// refreshed this tick, and reuses the free/total `get_disk_data` just
1030/// computed rather than re-reading them.
1031#[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/// Summarise one already-collected volume as the watch target.
1042///
1043/// Reads the `disk:vol:<mount>:*` figures `get_disk_data` wrote rather than
1044/// re-deriving them, which keeps the pill and the popover row arithmetically
1045/// identical by construction. Pure w.r.t. the map, so the partial-tick
1046/// behaviour below is testable.
1047///
1048/// A mount `get_disk_data` skipped (zero capacity, torn tick) has no figures
1049/// to summarise: emit nothing rather than a fabricated 0, matching
1050/// `parseDiskVolumes`, which drops partial volumes for the same reason.
1051#[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
1074/// Read the telemetry interval from config, clamped to [MIN, MAX].
1075fn 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
1083/// Run the sysinfo collection loop. Uses `tokio::time::interval` for steady
1084/// tick rate regardless of refresh duration. Interval is re-read from config
1085/// each tick and the timer is reset if it changes.
1086pub 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    // Commit-attribution cadence — see the "Commit (pagefile) attribution"
1099    // section above. Starts due-now-minus-interval so the first snapshot
1100    // establishes a baseline shortly after startup, not 30s of silence.
1101    // Windows-only — see the matching #[cfg] on the loop body below.
1102    #[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    // Each pane badge's highest memory, for its tooltip.
1110    let mut peaks = crate::backend::blockstats_detail::Peaks::default();
1111
1112    // What to measure and how often: a source runs only while something holds a
1113    // lease on it (backend/sampler). The machine's counters are the baseline. The
1114    // panes' processes run at the telemetry rate only while a pane badge is
1115    // subscribed, and otherwise just often enough to evict a pane whose shell has
1116    // died. docs/reports/REPORT_SYSINFO_REWRITE_AND_MODERN_MONITOR_2026_10_13.md
1117    // section 5.5.
1118    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            // Never stretched: the machine's counters are what the CPU chart shows,
1124            // and a busy machine is exactly when they must keep coming. Only the
1125            // costly pane scan slows down under load.
1126            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    // Both start one interval in: the CPU counters need two reads to mean anything.
1136    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            // Refresh all metrics. All sysinfo refresh calls are synchronous
1169            // /proc reads; block_in_place signals the Tokio runtime to keep
1170            // other tasks (including terminal echo) running on other threads
1171            // while this thread is occupied — preventing 1Hz echo starvation.
1172            //
1173            // `get_pagefile_volume_data`'s `GetDiskFreeSpaceExW` call (reagent
1174            // P1, PR #2109) is a synchronous Win32 disk I/O syscall — the exact
1175            // hazard this block exists to isolate, unlike `get_commit_data`'s
1176            // `GlobalMemoryStatusEx` (pure in-memory, no filesystem driver
1177            // involved) — so its result is computed here too, alongside the
1178            // other blocking OS reads, not in the async section below.
1179            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            // Must follow get_disk_data: it summarises the per-volume figures that
1206            // call just wrote. The Windows equivalent already ran inside
1207            // block_in_place above (it makes a real syscall); this one does not.
1208            #[cfg(not(target_os = "windows"))]
1209            get_watch_volume_data(&disks, &mut values);
1210
1211            // Commit attribution: periodic in the steady state, or immediately
1212            // (debounced) when commit heads toward exhaustion — see
1213            // docs/retro/retro-subagent-backfill-storm-oom-2026-07-17.md. Reuses
1214            // the commit numbers just computed above rather than re-querying.
1215            // Windows-only like every other commit/pagefile helper in this file
1216            // (get_commit_data, available_commit_gb, get_pagefile_volume_data) —
1217            // its rationale (pagefile/commit exhaustion) doesn't apply elsewhere,
1218            // and off-Windows `mem:commit:*` are never populated, so this would
1219            // otherwise pay a full process-table refresh every tick just to log
1220            // a permanently-zero commit_used_gb/commit_total_gb.
1221            #[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            // Each pane's processes as Tower groups them (its Job Object or cgroup,
1256            // else its shell's tree), from Tower's own sample, so the badge, its
1257            // detail and Tower count the same processes (`sampler::panes`).
1258            // Read before the sample: a shell registered after it was read can't be
1259            // judged by it.
1260            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                        // Per-block process stats, not the backend's own uptime.
1275                        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            // Last-resort cleanup for a shell that exited without the normal
1286            // teardown (killed by the OS, a crash, a stop() race): its PID is
1287            // missing from a process table read after it was registered. Only a
1288            // sample taken in this pass counts; a reused or failed one is skipped,
1289            // and the next pass decides.
1290            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    /// Neither property this fix depends on — immunity to a clock step, and
1311    /// counting across suspend — can be asserted in a unit test: the platform
1312    /// counters are deliberately unsettable, and suspending the machine isn't
1313    /// something a test can do. What IS assertable: the marker is idempotent
1314    /// (a stray second call can't restart the clock) and the value never runs
1315    /// backwards. The `u64` return type is itself part of the fix — the old
1316    /// wall-clock subtraction was signed and reached about -1.7e9.
1317    #[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    /// The suspend-aware counter must be a sane, forward-running millisecond
1327    /// source on whatever platform this is built for — a `clock_gettime`
1328    /// failure returning 0, or a units mix-up, would show up here.
1329    #[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        // Sanity on units: a machine that booted more than ~10 years ago is a
1336        // units error (ns or µs mistaken for ms), not a real uptime.
1337        assert!(a < 10 * 365 * 24 * 60 * 60 * 1_000, "implausible ms reading: {a}");
1338    }
1339
1340    /// The frontend's fallback path keys off this field's ABSENCE, and the
1341    /// per-block stats / CPU-stream payloads must keep their exact prior
1342    /// shape — so `skip_serializing_if` is a wire contract, not a style choice.
1343    #[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    /// A payload produced before this field existed (e.g. replayed from the
1358    /// sysinfo persist ring across an upgrade) must still deserialize.
1359    #[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    /// The case that motivated longest-prefix: `/` also prefixes the data
1374    /// dir, and picking it would report free space on the wrong filesystem
1375    /// whenever `/home` is separately mounted.
1376    #[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    /// A sibling must never match: `/home2` is not a prefix of `/home/yas`,
1397    /// and a naive string `starts_with` would say it is.
1398    #[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    /// Summary keys are derived from what `get_disk_data` already wrote, so a
1410    /// volume it skipped (zero capacity, torn tick) summarises to nothing
1411    /// rather than a fabricated zero.
1412    #[test]
1413    fn partial_volume_emits_no_summary() {
1414        let mut values: HashMap<String, f64> = HashMap::new();
1415        // total_gb present, free_gb missing — a torn tick.
1416        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    /// The percentage must be derived from the same figures the popover row
1431    /// shows, or the pill and the row can disagree about the same drive.
1432    #[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        // The night this spec was written: 0.55% free, and nothing said so.
1454        assert!(values["disk:watch:free_pct"] < 1.0);
1455    }
1456}