Skip to main content

omnyssh_core/ssh/
pool.rs

1//! Background metrics polling pool.
2//!
3//! Each host gets its own persistent tokio task that manages its SSH
4//! connection and collects metrics at a configurable interval.
5//!
6//! Architecture:
7//! - [`PollManager`] — created by the main app, owns abort handles.
8//! - One `HostPoller` task per host — loops indefinitely until aborted.
9//! - Implements exponential backoff on connection failures.
10//! - One SSH connection per host, reused across polls.
11//! - All data sent to the main event loop via `mpsc::Sender<CoreEvent>`.
12
13use std::collections::HashMap;
14use std::time::{Duration, Instant};
15
16use tokio::sync::mpsc;
17use tokio::task::JoinHandle;
18
19use crate::event::{CoreEvent, Metrics, ProcessInfo};
20use crate::ssh::client::{ConnectionStatus, Host};
21use crate::ssh::metrics::{
22    parse_cpu_proc_stat, parse_cpu_top, parse_cpu_top_macos, parse_disk_df, parse_loadavg,
23    parse_ram_free, parse_ram_vmstat, parse_top_processes, parse_uptime,
24};
25use crate::ssh::session::SshSession;
26
27// ---------------------------------------------------------------------------
28// Backoff schedule
29// ---------------------------------------------------------------------------
30
31const BACKOFF_SECS: [u64; 4] = [30, 60, 120, 300];
32
33struct BackoffState {
34    step: usize,
35}
36
37impl BackoffState {
38    fn new() -> Self {
39        Self { step: 0 }
40    }
41
42    fn next_delay(&mut self) -> Duration {
43        let secs = BACKOFF_SECS[self.step];
44        self.step = (self.step + 1).min(BACKOFF_SECS.len() - 1);
45        Duration::from_secs(secs)
46    }
47
48    fn reset(&mut self) {
49        self.step = 0;
50    }
51}
52
53// ---------------------------------------------------------------------------
54// PollManager — owned by App, drives all HostPoller tasks
55// ---------------------------------------------------------------------------
56
57/// Manages background metric polling for all hosts.
58///
59/// Drop this struct to abort all poller tasks.
60pub struct PollManager {
61    task_handles: Vec<JoinHandle<()>>,
62    /// Per-host channel to send an immediate-refresh signal.
63    refresh_txs: HashMap<String, mpsc::Sender<()>>,
64}
65
66impl PollManager {
67    /// Spawn one poller task per host.
68    pub fn start(hosts: Vec<Host>, tx: mpsc::Sender<CoreEvent>, poll_interval: Duration) -> Self {
69        let mut task_handles = Vec::with_capacity(hosts.len());
70        let mut refresh_txs = HashMap::with_capacity(hosts.len());
71
72        for host in hosts {
73            let (refresh_tx, refresh_rx) = mpsc::channel::<()>(4);
74            refresh_txs.insert(host.name.clone(), refresh_tx);
75
76            let event_tx = tx.clone();
77            let interval = poll_interval;
78            let handle = tokio::spawn(run_host_poller(host, event_tx, interval, refresh_rx));
79            task_handles.push(handle);
80        }
81
82        Self {
83            task_handles,
84            refresh_txs,
85        }
86    }
87
88    /// Trigger an immediate poll for all hosts (called on `r` key press).
89    pub fn refresh_all(&self) {
90        for (name, tx) in &self.refresh_txs {
91            if tx.try_send(()).is_err() {
92                tracing::debug!(host = %name, "refresh signal dropped — channel full or closed");
93            }
94        }
95    }
96
97    /// Abort all poller tasks. Called on app exit to allow clean shutdown.
98    /// SSH sessions are dropped inside the tasks, which triggers
99    /// russh's graceful disconnect.
100    pub fn shutdown(self) {
101        for handle in &self.task_handles {
102            handle.abort();
103        }
104    }
105}
106
107// ---------------------------------------------------------------------------
108// Per-host poller task
109// ---------------------------------------------------------------------------
110
111async fn run_host_poller(
112    host: Host,
113    tx: mpsc::Sender<CoreEvent>,
114    poll_interval: Duration,
115    mut refresh_rx: mpsc::Receiver<()>,
116) {
117    let mut backoff = BackoffState::new();
118    let mut session: Option<SshSession> = None;
119    let mut discovery_done = false; // Track if we've done Quick Scan
120
121    loop {
122        // Ensure we have a live session.
123        if session.is_none() {
124            send_status(&tx, &host.name, ConnectionStatus::Connecting).await;
125            match SshSession::connect(&host).await {
126                Ok(s) => {
127                    backoff.reset();
128                    send_status(&tx, &host.name, ConnectionStatus::Connected).await;
129                    session = Some(s);
130                    discovery_done = false; // Reset discovery flag on new connection
131                }
132                Err(e) => {
133                    tracing::debug!(host = %host.name, error = %e, "connection failed");
134                    send_status(&tx, &host.name, ConnectionStatus::Failed(e.to_string())).await;
135                    // Wait with backoff, allowing early refresh.
136                    let delay = backoff.next_delay();
137                    wait_or_refresh(delay, &mut refresh_rx).await;
138                    continue;
139                }
140            }
141        }
142
143        // Run Quick Scan once per connection (don't block UI)
144        // This happens right after connection before the first metrics poll
145        if !discovery_done {
146            if let Some(sess) = &session {
147                // Run discovery asynchronously
148                // Clone the session since Handle is Arc-based and cheap to clone
149                let sess_clone = sess.clone();
150                let host_name = host.name.clone();
151                let tx_clone = tx.clone();
152                tokio::spawn(async move {
153                    match crate::ssh::discovery::quick_scan(
154                        &sess_clone,
155                        host_name.clone(),
156                        tx_clone.clone(),
157                    )
158                    .await
159                    {
160                        Ok(()) => {
161                            tracing::debug!(host = %host_name, "quick scan completed successfully");
162                        }
163                        Err(e) => {
164                            tracing::warn!(host = %host_name, error = %e, "quick scan failed");
165                            let _ = tx_clone
166                                .send(CoreEvent::DiscoveryFailed(host_name, e.to_string()))
167                                .await;
168                        }
169                    }
170                });
171                discovery_done = true;
172            }
173        }
174
175        // Collect metrics using the live session.
176        // SAFETY: we only reach this point if session was set to Some above
177        // (either just connected or carried over from the previous iteration).
178        // This is a single-task async loop with no concurrent mutation, so
179        // the expect is always satisfied.
180        let sess = session.as_ref().expect("session is Some here");
181        match collect_metrics(sess, &host.name).await {
182            Ok(metrics) => {
183                if tx
184                    .send(CoreEvent::MetricsUpdate(host.name.clone(), metrics))
185                    .await
186                    .is_err()
187                {
188                    break; // App has shut down.
189                }
190            }
191            Err(e) => {
192                tracing::debug!(host = %host.name, error = %e, "metric collection failed");
193                // Session is broken — drop it and reconnect next iteration.
194                session.take();
195                send_status(&tx, &host.name, ConnectionStatus::Failed(e.to_string())).await;
196                let delay = backoff.next_delay();
197                wait_or_refresh(delay, &mut refresh_rx).await;
198                continue;
199            }
200        }
201
202        // Wait for the next poll interval or a manual refresh signal.
203        wait_or_refresh(poll_interval, &mut refresh_rx).await;
204    }
205}
206
207/// Wait for `delay`, but return early if a refresh signal is received.
208async fn wait_or_refresh(delay: Duration, refresh_rx: &mut mpsc::Receiver<()>) {
209    tokio::select! {
210        _ = tokio::time::sleep(delay) => {}
211        _ = refresh_rx.recv() => {}
212    }
213}
214
215async fn send_status(tx: &mpsc::Sender<CoreEvent>, name: &str, status: ConnectionStatus) {
216    let _ = tx
217        .send(CoreEvent::HostStatusChanged(name.to_string(), status))
218        .await;
219}
220
221// ---------------------------------------------------------------------------
222// Metric collection
223// ---------------------------------------------------------------------------
224
225/// Run all metric commands and return a [`Metrics`] snapshot.
226///
227/// Tries Linux commands first. If the output doesn't match the expected
228/// format, falls back to macOS/BSD variants (graceful degradation per
229/// the risk matrix in tech.md §10).
230///
231/// Returns `Err` when all commands fail simultaneously — this indicates a dead
232/// session and should prompt the caller to reconnect.
233async fn collect_metrics(session: &SshSession, host_name: &str) -> anyhow::Result<Metrics> {
234    // Run all commands concurrently for speed.
235    let (cpu_out, mem_out, disk_out, uptime_out, loadavg_out) = tokio::join!(
236        session.run_command("top -bn1 2>/dev/null | head -5"),
237        session.run_command("free -b 2>/dev/null || vm_stat 2>/dev/null"),
238        session.run_command("df -k / 2>/dev/null"),
239        session.run_command("uptime 2>/dev/null"),
240        session.run_command("cat /proc/loadavg 2>/dev/null"),
241    );
242
243    // If every command failed the session is almost certainly dead — return an
244    // error so the poller drops the session and reconnects.
245    if cpu_out.is_err()
246        && mem_out.is_err()
247        && disk_out.is_err()
248        && uptime_out.is_err()
249        && loadavg_out.is_err()
250    {
251        let err = cpu_out
252            .err()
253            .unwrap_or_else(|| anyhow::anyhow!("all metric commands failed"));
254        return Err(anyhow::anyhow!(
255            "all metric commands failed (session may be dead): {}",
256            err
257        ));
258    }
259
260    // Log individual command failures at debug level so operators can distinguish
261    // "metric unavailable on this OS" from "command errored".
262    let cpu_str = cpu_out
263        .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "cpu command failed"))
264        .unwrap_or_default();
265    let mem_str = mem_out
266        .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "mem command failed"))
267        .unwrap_or_default();
268    let disk_str = disk_out
269        .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "disk command failed"))
270        .unwrap_or_default();
271    let uptime_str = uptime_out
272        .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "uptime command failed"))
273        .unwrap_or_default();
274    let loadavg_str = loadavg_out
275        .inspect_err(|e| tracing::debug!(host = %host_name, error = %e, "loadavg command failed"))
276        .unwrap_or_default();
277
278    let cpu_percent = parse_cpu_combined(&cpu_str, session).await;
279
280    let ram_percent = parse_ram_combined(&mem_str, session).await;
281
282    let disk_percent = parse_disk_df(&disk_str).or_else(|| {
283        if !disk_str.is_empty() {
284            tracing::debug!(host = %host_name, "disk output present but parse failed");
285        }
286        None
287    });
288
289    let uptime = parse_uptime(&uptime_str);
290
291    let load_avg = parse_loadavg(&loadavg_str);
292
293    let top_processes = collect_top_processes(session).await;
294
295    Ok(Metrics {
296        cpu_percent,
297        ram_percent,
298        disk_percent,
299        uptime,
300        load_avg,
301        os_info: None, // OS info is collected during discovery, not metrics polling
302        top_processes,
303        last_updated: Instant::now(),
304    })
305}
306
307/// Collects the top 3 processes by CPU usage.
308///
309/// Tries GNU `ps` (Linux) with a server-side sort first, then falls back to
310/// BSD `ps` (macOS). Returns `None` when neither variant yields usable output.
311async fn collect_top_processes(session: &SshSession) -> Option<Vec<ProcessInfo>> {
312    // Linux: GNU ps with server-side sort by CPU; empty `=` headers suppressed.
313    let linux_out = session
314        .run_command(&top_processes_command(
315            "-eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu",
316        ))
317        .await
318        .unwrap_or_default();
319    if let Some(procs) = parse_top_processes(&linux_out) {
320        return Some(procs);
321    }
322    // macOS: BSD ps sorted by CPU usage (-r).
323    let macos_out = session
324        .run_command(&top_processes_command(
325            "-Aceo pid=,ppid=,pcpu=,pmem=,comm= -r",
326        ))
327        .await
328        .unwrap_or_default();
329    parse_top_processes(&macos_out)
330}
331
332/// Builds the remote shell command that lists the top processes by CPU with
333/// the monitoring connection's own process chain removed. `ps_args` selects
334/// the OS-specific `ps` columns and sort order.
335///
336/// The pipeline runs inside an SSH-spawned shell whose own processes — and the
337/// `sshd` hosting the connection — would otherwise dominate the snapshot on an
338/// idle server. The `awk` filter drops them strictly by PID:
339///
340/// - `s` (`$$`) — the shell, and everything it forked (`ps`, `awk`, `head`);
341/// - `p` (`$PPID`) — the connection's `sshd`, and its children;
342/// - `g` — the privileged `sshd` one level up (parent of `$PPID`).
343///
344/// Filtering is by PID only, never by process name, so a genuinely busy SSH
345/// session belonging to another user still appears. POSIX-sh syntax — a
346/// non-Bourne login shell simply yields no output and the panel degrades to
347/// "process data unavailable".
348fn top_processes_command(ps_args: &str) -> String {
349    format!(
350        "g=$(ps -o ppid= -p $PPID 2>/dev/null | tr -d ' '); \
351         ps {ps_args} 2>/dev/null | \
352         awk -v s=$$ -v p=$PPID -v g=\"$g\" \
353         '$1!=s && $1!=p && $1!=g && $2!=s && $2!=p \
354         {{$1=\"\";$2=\"\";sub(/^[ \\t]+/,\"\");print}}' | \
355         head -n 3"
356    )
357}
358
359async fn parse_cpu_combined(top_out: &str, session: &SshSession) -> Option<f64> {
360    // Try Linux top format first.
361    if let Some(v) = parse_cpu_top(top_out) {
362        return Some(v);
363    }
364    // Try macOS top format.
365    let macos_out = session
366        .run_command("top -l 1 -n 0 2>/dev/null | grep 'CPU usage'")
367        .await
368        .unwrap_or_default();
369    if let Some(v) = parse_cpu_top_macos(&macos_out) {
370        return Some(v);
371    }
372    // Fall back to /proc/stat.
373    let stat_out = session
374        .run_command("head -1 /proc/stat 2>/dev/null")
375        .await
376        .unwrap_or_default();
377    parse_cpu_proc_stat(&stat_out)
378}
379
380async fn parse_ram_combined(mem_out: &str, session: &SshSession) -> Option<f64> {
381    // Try Linux free -b output.
382    if let Some(v) = parse_ram_free(mem_out) {
383        return Some(v);
384    }
385    // vm_stat output (macOS) — also need sysctl hw.memsize.
386    if mem_out.contains("Mach Virtual Memory") {
387        let memsize_out = session
388            .run_command("sysctl hw.memsize 2>/dev/null")
389            .await
390            .unwrap_or_default();
391        return parse_ram_vmstat(mem_out, &memsize_out);
392    }
393    None
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn top_processes_command_excludes_monitor_pid_chain() {
402        let cmd = top_processes_command("-eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu");
403
404        // The grandparent PID is resolved before the pipeline runs.
405        assert!(cmd.contains("g=$(ps -o ppid= -p $PPID"));
406        // The awk filter binds the shell, its parent sshd and the grandparent.
407        assert!(cmd.contains("-v s=$$"));
408        assert!(cmd.contains("-v p=$PPID"));
409        assert!(cmd.contains("-v g=\"$g\""));
410        // It drops all three PIDs (and the children of the shell and sshd).
411        assert!(cmd.contains("$1!=s && $1!=p && $1!=g && $2!=s && $2!=p"));
412        // Filtering is by PID only — never by process name.
413        assert!(!cmd.contains("sshd"));
414        // Output is capped server-side.
415        assert!(cmd.trim_end().ends_with("head -n 3"));
416    }
417
418    #[test]
419    fn top_processes_command_splices_ps_args_verbatim() {
420        let linux = top_processes_command("-eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu");
421        assert!(linux.contains("ps -eo pid=,ppid=,pcpu=,pmem=,comm= --sort=-pcpu 2>/dev/null"));
422
423        let macos = top_processes_command("-Aceo pid=,ppid=,pcpu=,pmem=,comm= -r");
424        assert!(macos.contains("ps -Aceo pid=,ppid=,pcpu=,pmem=,comm= -r 2>/dev/null"));
425    }
426}