Skip to main content

rigtop_core/
remote.rs

1//! A [`DataSource`] for a remote host, reached by running `rigtop --agent`
2//! through the system's `ssh` (OpenSSH; `ssh.exe` on Windows).
3//!
4//! Using the system client means `~/.ssh/config`, keys, agents and
5//! `ProxyJump` all work as on the command line. Authentication must not
6//! need a prompt (`BatchMode`), since there is no terminal to ask on.
7
8use std::collections::VecDeque;
9use std::io::{BufRead, BufReader};
10use std::process::{Child, ChildStdin, Command, Stdio};
11use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
12use std::sync::{Arc, Mutex};
13use std::thread;
14use std::time::{Duration, Instant};
15
16use crate::agent::{
17    AgentMessage, ClientMessage, FEATURE_PROCESS_CONTROL, FEATURE_WATCH_PROCESSES,
18    PROTOCOL_VERSION, write_message,
19};
20use crate::snapshot::Snapshot;
21use crate::source::{DataSource, ProcessSignal, SourceError};
22
23/// Command run on the remote host by default.
24pub const DEFAULT_REMOTE_COMMAND: &str = "rigtop --agent";
25/// Tried in order when the default command is not found: the install
26/// script puts rigtop in `~/.local/bin` and `cargo install` in
27/// `~/.cargo/bin`, which non-interactive SSH sessions often lack on their
28/// `PATH`. Release binaries come first: they update without building.
29pub const FALLBACK_REMOTE_COMMANDS: [&str; 2] =
30    ["~/.local/bin/rigtop --agent", "~/.cargo/bin/rigtop --agent"];
31/// Exit status of POSIX shells for "command not found".
32const NOT_FOUND_STATUS: i32 = 127;
33
34/// Minimum time between connection attempts after a failure.
35const RECONNECT_DELAY: Duration = Duration::from_secs(5);
36/// How long to wait for the first snapshot of a new connection (SSH
37/// handshake plus the agent's warm-up sample).
38const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
39/// How long to wait for the agent to answer a signal request.
40const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
41/// stderr lines kept to explain failures.
42const STDERR_LINES: usize = 20;
43
44/// How to start the agent: a program and its arguments.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct RemoteTarget {
47    /// Short name for messages (e.g. the SSH destination).
48    pub name: String,
49    /// Program to run (normally `ssh`).
50    pub program: String,
51    /// Its arguments.
52    pub args: Vec<String>,
53    /// Arguments to try in order when the remote command is not found
54    /// (for the default command: [`FALLBACK_REMOTE_COMMANDS`]).
55    pub fallback_args: Vec<Vec<String>>,
56}
57
58impl RemoteTarget {
59    /// Runs `remote_command` on `destination` (anything `ssh` accepts:
60    /// a `~/.ssh/config` host, `user@host`, ...).
61    pub fn ssh(destination: &str, remote_command: &str) -> Self {
62        let options = [
63            // No password or host key prompts: fail instead of hanging.
64            "BatchMode=yes",
65            "ConnectTimeout=10",
66            // Notice dead connections within ~45 s.
67            "ServerAliveInterval=15",
68            "ServerAliveCountMax=3",
69        ];
70        let mut args = vec!["-T".to_owned()];
71        for option in options {
72            args.push("-o".to_owned());
73            args.push(option.to_owned());
74        }
75        // `--` keeps a destination starting with "-" from being an option.
76        args.push("--".to_owned());
77        args.push(destination.to_owned());
78        args.push(remote_command.to_owned());
79        // Only the default command falls back; one set by the user is
80        // taken as it is.
81        let fallback_args = if remote_command == DEFAULT_REMOTE_COMMAND {
82            FALLBACK_REMOTE_COMMANDS
83                .iter()
84                .map(|command| {
85                    let mut fallback = args.clone();
86                    *fallback.last_mut().expect("command pushed above") = (*command).to_owned();
87                    fallback
88                })
89                .collect()
90        } else {
91            Vec::new()
92        };
93        Self {
94            name: destination.to_owned(),
95            program: "ssh".to_owned(),
96            args,
97            fallback_args,
98        }
99    }
100}
101
102/// What the reader thread passes on.
103enum Incoming {
104    Message(AgentMessage),
105    /// stdout closed: the agent or ssh exited.
106    Closed,
107}
108
109/// One running agent process.
110struct Connection {
111    child: Child,
112    stdin: ChildStdin,
113    incoming: Receiver<Incoming>,
114    /// Last lines of the process's stderr (ssh errors, "command not found").
115    stderr: Arc<Mutex<VecDeque<String>>>,
116    /// Whether a snapshot has arrived on this connection yet.
117    established: bool,
118}
119
120impl Connection {
121    fn spawn(target: &RemoteTarget, args: &[String]) -> Result<Self, SourceError> {
122        let mut command = Command::new(&target.program);
123        command
124            .args(args)
125            .stdin(Stdio::piped())
126            .stdout(Stdio::piped())
127            .stderr(Stdio::piped());
128        #[cfg(windows)]
129        {
130            use std::os::windows::process::CommandExt;
131            // CREATE_NO_WINDOW: a GUI must not flash a console for ssh.exe.
132            command.creation_flags(0x0800_0000);
133        }
134        let mut child = command.spawn().map_err(|err| {
135            SourceError::Disconnected(format!("cannot start {}: {err}", target.program))
136        })?;
137        let stdin = child.stdin.take().expect("piped stdin");
138        let stdout = child.stdout.take().expect("piped stdout");
139        let stderr_pipe = child.stderr.take().expect("piped stderr");
140
141        let (tx, incoming) = mpsc::channel();
142        thread::Builder::new()
143            .name("rigtop-remote-reader".to_owned())
144            .spawn(move || {
145                for line in BufReader::new(stdout).lines() {
146                    let Ok(line) = line else { break };
147                    // Lines that are not protocol messages (e.g. a login
148                    // banner printed by a shell profile) are skipped.
149                    if let Ok(message) = serde_json::from_str::<AgentMessage>(&line)
150                        && tx.send(Incoming::Message(message)).is_err()
151                    {
152                        return;
153                    }
154                }
155                let _ = tx.send(Incoming::Closed);
156            })
157            .map_err(SourceError::Io)?;
158
159        let stderr = Arc::new(Mutex::new(VecDeque::new()));
160        let lines = Arc::clone(&stderr);
161        thread::Builder::new()
162            .name("rigtop-remote-stderr".to_owned())
163            .spawn(move || {
164                for line in BufReader::new(stderr_pipe).lines() {
165                    let Ok(line) = line else { break };
166                    let mut lines = lines.lock().unwrap_or_else(|e| e.into_inner());
167                    if lines.len() == STDERR_LINES {
168                        lines.pop_front();
169                    }
170                    lines.push_back(line);
171                }
172            })
173            .map_err(SourceError::Io)?;
174
175        Ok(Self {
176            child,
177            stdin,
178            incoming,
179            stderr,
180            established: false,
181        })
182    }
183
184    fn send(&mut self, message: &ClientMessage) -> Result<(), SourceError> {
185        write_message(&mut self.stdin, message).map_err(SourceError::Io)
186    }
187
188    /// Explanation for a lost connection, from the process's stderr, and
189    /// whether the remote command was not found.
190    fn failure(&mut self) -> (String, bool) {
191        // Give the stderr thread a moment to collect the final lines.
192        thread::sleep(Duration::from_millis(100));
193        let status = self.child.try_wait().ok().flatten();
194        let lines = self.stderr.lock().unwrap_or_else(|e| e.into_inner());
195        let detail = lines
196            .iter()
197            .rev()
198            .find(|l| !l.trim().is_empty())
199            .cloned()
200            .unwrap_or_default();
201        // ssh passes the remote exit status on; cmd.exe says "not recognized".
202        let not_found = status.and_then(|s| s.code()) == Some(NOT_FOUND_STATUS)
203            || detail.contains("command not found")
204            || detail.contains("not recognized");
205        let reason = match (detail.is_empty(), status) {
206            (false, _) => detail,
207            (true, Some(status)) => format!("connection closed ({status})"),
208            (true, None) => "connection closed".to_owned(),
209        };
210        (reason, not_found)
211    }
212}
213
214impl Drop for Connection {
215    fn drop(&mut self) {
216        // Closing stdin makes the agent exit; killing ssh ends the session
217        // right away instead of waiting for it.
218        let _ = self.child.kill();
219        let _ = self.child.wait();
220    }
221}
222
223/// Snapshots from `rigtop --agent` on another machine.
224pub struct RemoteSource {
225    target: RemoteTarget,
226    connection: Option<Connection>,
227    last_attempt: Option<Instant>,
228    interval: Duration,
229    process_limit: Option<usize>,
230    /// See [`DataSource::watch_processes`].
231    watched_pids: Vec<u32>,
232    /// See [`DataSource::watch_process_names`].
233    watched_names: Vec<String>,
234    /// The connected agent understands [`ClientMessage::WatchProcesses`].
235    agent_watches: bool,
236    /// The connected agent can pause, resume and renice processes.
237    agent_controls: bool,
238    /// Snapshots received while waiting for something else (signal results).
239    pending: VecDeque<Snapshot>,
240    /// rigtop version from the agent's greeting.
241    agent_version: String,
242    /// Why the last connection failed, repeated while waiting to retry.
243    last_error: Option<String>,
244    /// Which command starts the agent: 0 for the configured one, then the
245    /// fallbacks in order.
246    attempt: usize,
247    /// The command was not found and the next one should be tried at once.
248    retry_now: bool,
249}
250
251impl RemoteSource {
252    /// Creates a source; the connection is made on the first poll.
253    pub fn new(target: RemoteTarget) -> Self {
254        Self {
255            target,
256            connection: None,
257            last_attempt: None,
258            interval: Duration::from_secs(1),
259            process_limit: None,
260            watched_pids: Vec::new(),
261            watched_names: Vec::new(),
262            agent_watches: false,
263            agent_controls: false,
264            pending: VecDeque::new(),
265            agent_version: String::new(),
266            last_error: None,
267            attempt: 0,
268            retry_now: false,
269        }
270    }
271
272    /// The target this source connects to.
273    pub fn target(&self) -> &RemoteTarget {
274        &self.target
275    }
276
277    /// Returns the connection, (re)connecting if needed and allowed.
278    fn connection(&mut self) -> Result<&mut Connection, SourceError> {
279        if self.connection.is_none() {
280            if let Some(at) = self.last_attempt {
281                let wait = RECONNECT_DELAY.saturating_sub(at.elapsed());
282                if !wait.is_zero() {
283                    let reason = self.last_error.as_deref().unwrap_or("not connected");
284                    return Err(SourceError::Disconnected(format!(
285                        "{}: {reason} (retrying in {}s)",
286                        self.target.name,
287                        wait.as_secs() + 1
288                    )));
289                }
290            }
291            self.last_attempt = Some(Instant::now());
292            let args = match self.attempt {
293                0 => &self.target.args,
294                n => self
295                    .target
296                    .fallback_args
297                    .get(n - 1)
298                    .unwrap_or(&self.target.args),
299            };
300            let mut connection = match Connection::spawn(&self.target, args) {
301                Ok(connection) => connection,
302                Err(err) => {
303                    self.last_error = Some(match &err {
304                        SourceError::Disconnected(reason) => reason.clone(),
305                        other => other.to_string(),
306                    });
307                    return Err(err);
308                }
309            };
310            // Known from the greeting of this agent.
311            self.agent_watches = false;
312            // Agents start with their own default; send our settings.
313            let sent = connection
314                .send(&ClientMessage::SetInterval {
315                    ms: self.interval.as_millis() as u64,
316                })
317                .and_then(|()| {
318                    connection.send(&ClientMessage::SetProcessLimit {
319                        limit: self.process_limit,
320                    })
321                });
322            self.connection = Some(connection);
323            if sent.is_err() {
324                // ssh already exited (bad host, auth failure): its stderr
325                // says why, which beats "broken pipe".
326                return Err(self.disconnect(None));
327            }
328        }
329        Ok(self.connection.as_mut().expect("connected above"))
330    }
331
332    /// Drops the connection and returns an error explaining why.
333    fn disconnect(&mut self, reason: Option<String>) -> SourceError {
334        let (mut reason, not_found) = match (reason, self.connection.as_mut()) {
335            (Some(reason), _) => (reason, false),
336            (None, Some(connection)) => connection.failure(),
337            (None, None) => ("connection closed".to_owned(), false),
338        };
339        self.connection = None;
340        if not_found && !self.target.fallback_args.is_empty() {
341            if self.attempt < self.target.fallback_args.len() {
342                self.attempt += 1;
343                self.retry_now = true;
344                self.last_attempt = None;
345            } else {
346                // None worked; start over with the PATH next time, in case
347                // rigtop gets installed there.
348                self.attempt = 0;
349                reason = format!(
350                    "rigtop is not on the PATH, in ~/.local/bin or in ~/.cargo/bin ({reason})"
351                );
352            }
353        }
354        let error = SourceError::Disconnected(format!("{}: {reason}", self.target.name));
355        self.last_error = Some(reason);
356        error
357    }
358
359    /// Waits up to `timeout` for the next message.
360    fn next_message(&mut self, timeout: Duration) -> Result<Option<AgentMessage>, SourceError> {
361        let connection = self.connection()?;
362        match connection.incoming.recv_timeout(timeout) {
363            Ok(Incoming::Message(message)) => Ok(Some(message)),
364            Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => {
365                Err(self.disconnect(None))
366            }
367            Err(RecvTimeoutError::Timeout) => Ok(None),
368        }
369    }
370
371    /// Handles messages that are not what the caller waits for. Returns an
372    /// error for fatal ones (protocol mismatch).
373    /// Sends the watched processes to the connected agent.
374    fn send_watched(&mut self) {
375        if let Some(connection) = self.connection.as_mut() {
376            let _ = connection.send(&ClientMessage::WatchProcesses {
377                pids: self.watched_pids.clone(),
378                // Agents without the feature ignore them.
379                names: self.watched_names.clone(),
380            });
381        }
382    }
383
384    fn absorb(&mut self, message: AgentMessage) -> Result<(), SourceError> {
385        match message {
386            AgentMessage::Hello {
387                protocol, version, ..
388            } if protocol != PROTOCOL_VERSION => {
389                let reason = format!(
390                    "agent speaks protocol {protocol} (rigtop {version}), this client speaks {PROTOCOL_VERSION}; \
391                     install the same rigtop version on both sides"
392                );
393                // Do not retry quickly against an incompatible agent.
394                self.last_attempt = Some(Instant::now());
395                Err(self.disconnect(Some(reason)))
396            }
397            AgentMessage::Hello {
398                version, features, ..
399            } => {
400                self.agent_version = version;
401                self.agent_watches = features.iter().any(|f| f == FEATURE_WATCH_PROCESSES);
402                self.agent_controls = features.iter().any(|f| f == FEATURE_PROCESS_CONTROL);
403                if self.agent_watches
404                    && !(self.watched_pids.is_empty() && self.watched_names.is_empty())
405                {
406                    self.send_watched();
407                }
408                Ok(())
409            }
410            AgentMessage::SignalResult { .. } => Ok(()),
411            AgentMessage::Snapshot { mut snapshot } => {
412                // Agents before 0.9.1 do not put their version in snapshots.
413                if snapshot.host.rigtop_version.is_empty() {
414                    snapshot.host.rigtop_version = self.agent_version.clone();
415                }
416                if let Some(connection) = self.connection.as_mut() {
417                    connection.established = true;
418                }
419                self.last_error = None;
420                self.pending.push_back(*snapshot);
421                Ok(())
422            }
423            AgentMessage::Error { message } => Err(SourceError::Remote(message)),
424        }
425    }
426}
427
428impl RemoteSource {
429    /// One attempt at [`DataSource::poll`].
430    fn poll_once(&mut self) -> Result<Snapshot, SourceError> {
431        // A new connection needs time for SSH and the agent's warm-up.
432        let established = self.connection.as_ref().is_some_and(|c| c.established);
433        let timeout = if established {
434            self.interval * 3 + Duration::from_secs(5)
435        } else {
436            CONNECT_TIMEOUT
437        };
438        let deadline = Instant::now() + timeout;
439        loop {
440            // Drain everything already received; keep only the newest snapshot.
441            while let Some(message) = self.next_message(Duration::ZERO)? {
442                self.absorb(message)?;
443            }
444            if let Some(newest) = self.pending.pop_back() {
445                self.pending.clear();
446                return Ok(newest);
447            }
448            let remaining = deadline.saturating_duration_since(Instant::now());
449            if remaining.is_zero() {
450                return Err(self.disconnect(Some("no data from the agent (timed out)".to_owned())));
451            }
452            if let Some(message) = self.next_message(remaining)? {
453                self.absorb(message)?;
454            }
455        }
456    }
457}
458
459impl DataSource for RemoteSource {
460    /// Returns the newest snapshot from the agent, waiting for one if none
461    /// arrived since the previous poll.
462    fn poll(&mut self) -> Result<Snapshot, SourceError> {
463        loop {
464            match self.poll_once() {
465                // Not found: try the next place (~/.local/bin, then
466                // ~/.cargo/bin) right away.
467                Err(_) if std::mem::take(&mut self.retry_now) => continue,
468                result => return result,
469            }
470        }
471    }
472
473    fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
474        if signal.is_control() {
475            // Connect first, so the agent's features are known.
476            self.connection()?;
477            if !self.agent_controls {
478                return Err(SourceError::Remote(format!(
479                    "rigtop {} on the host cannot do this; it needs 0.22 or later",
480                    if self.agent_version.is_empty() {
481                        "(unknown)"
482                    } else {
483                        &self.agent_version
484                    }
485                )));
486            }
487        }
488        if self
489            .connection()?
490            .send(&ClientMessage::Signal { pid, signal })
491            .is_err()
492        {
493            return Err(self.disconnect(None));
494        }
495        let deadline = Instant::now() + SIGNAL_TIMEOUT;
496        loop {
497            let remaining = deadline.saturating_duration_since(Instant::now());
498            if remaining.is_zero() {
499                return Err(SourceError::Remote(format!(
500                    "no answer to the signal for {pid}"
501                )));
502            }
503            match self.next_message(remaining)? {
504                Some(AgentMessage::SignalResult {
505                    pid: answered,
506                    error,
507                    ..
508                }) if answered == pid => {
509                    return match error {
510                        None => Ok(()),
511                        Some(error) => Err(SourceError::Remote(error)),
512                    };
513                }
514                Some(other) => self.absorb(other)?,
515                None => {}
516            }
517        }
518    }
519
520    fn set_interval(&mut self, interval: Duration) {
521        self.interval = interval;
522        if let Some(connection) = self.connection.as_mut() {
523            let _ = connection.send(&ClientMessage::SetInterval {
524                ms: interval.as_millis() as u64,
525            });
526        }
527    }
528
529    fn set_process_limit(&mut self, limit: Option<usize>) {
530        self.process_limit = limit;
531        if let Some(connection) = self.connection.as_mut() {
532            let _ = connection.send(&ClientMessage::SetProcessLimit { limit });
533        }
534    }
535
536    fn watch_processes(&mut self, pids: Vec<u32>) {
537        self.watched_pids = pids;
538        // Older agents would answer with an error; their snapshots leave
539        // `watched_pids` empty, so the client knows it cannot tell.
540        if self.agent_watches {
541            self.send_watched();
542        }
543    }
544
545    fn watch_process_names(&mut self, names: Vec<String>) {
546        self.watched_names = names;
547        if self.agent_watches {
548            self.send_watched();
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn ssh_target_uses_batch_mode_and_keeps_the_destination_safe() {
559        let target = RemoteTarget::ssh("zefat", DEFAULT_REMOTE_COMMAND);
560        assert_eq!(target.program, "ssh");
561        assert_eq!(target.name, "zefat");
562        assert!(target.args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
563        let n = target.args.len();
564        assert_eq!(&target.args[n - 3..], ["--", "zefat", "rigtop --agent"]);
565        // The default command falls back to ~/.local/bin, then ~/.cargo/bin ...
566        let fallbacks: Vec<&str> = target
567            .fallback_args
568            .iter()
569            .map(|args| args[n - 1].as_str())
570            .collect();
571        assert_eq!(fallbacks, FALLBACK_REMOTE_COMMANDS);
572        assert_eq!(&target.fallback_args[0][n - 3..n - 1], ["--", "zefat"]);
573        // ... a command the user chose does not.
574        let custom = RemoteTarget::ssh("zefat", "/opt/rigtop/bin/rigtop --agent");
575        assert!(custom.fallback_args.is_empty());
576    }
577
578    /// `sh -c <script>` standing in for ssh.
579    #[cfg(unix)]
580    fn sh_target(script: &str, fallbacks: &[&str]) -> RemoteTarget {
581        let args = |script: &str| vec!["-c".to_owned(), script.to_owned()];
582        RemoteTarget {
583            name: "fake".to_owned(),
584            program: "sh".to_owned(),
585            args: args(script),
586            fallback_args: fallbacks.iter().map(|script| args(script)).collect(),
587        }
588    }
589
590    #[cfg(unix)]
591    #[test]
592    fn command_not_found_tries_the_fallback_at_once() {
593        // Found nowhere: every attempt in one poll, and a clear message.
594        let missing = "echo 'zsh:1: command not found: rigtop' >&2; exit 127";
595        let mut source = RemoteSource::new(sh_target(missing, &[missing, missing]));
596        let err = source.poll().unwrap_err().to_string();
597        assert!(
598            err.contains("not on the PATH, in ~/.local/bin or in ~/.cargo/bin"),
599            "{err}"
600        );
601        assert_eq!(source.attempt, 0, "the PATH comes first again next time");
602
603        // Other failures do not switch to the fallbacks.
604        let mut source = RemoteSource::new(sh_target(
605            "echo 'Permission denied (publickey)' >&2; exit 255",
606            &[missing],
607        ));
608        let err = source.poll().unwrap_err().to_string();
609        assert!(err.contains("Permission denied"), "{err}");
610        assert_eq!(source.attempt, 0);
611    }
612
613    #[test]
614    fn missing_program_is_reported_and_retried_later() {
615        let mut source = RemoteSource::new(RemoteTarget {
616            name: "nowhere".to_owned(),
617            program: "/nonexistent/ssh".to_owned(),
618            args: Vec::new(),
619            fallback_args: Vec::new(),
620        });
621        let err = source.poll().unwrap_err().to_string();
622        assert!(err.contains("cannot start"), "{err}");
623        // The next attempt waits for the reconnect delay, repeating the cause.
624        let err = source.poll().unwrap_err().to_string();
625        assert!(
626            err.contains("retrying") && err.contains("cannot start"),
627            "{err}"
628        );
629    }
630
631    #[cfg(unix)]
632    #[test]
633    fn stderr_explains_an_agent_that_exits() {
634        // Stands in for ssh failing, e.g. "rigtop: command not found".
635        let mut source = RemoteSource::new(RemoteTarget {
636            name: "fake".to_owned(),
637            program: "sh".to_owned(),
638            args: vec![
639                "-c".to_owned(),
640                "echo 'rigtop: command not found' >&2; exit 127".to_owned(),
641            ],
642            fallback_args: Vec::new(),
643        });
644        let err = source.poll().unwrap_err().to_string();
645        assert!(err.contains("fake: rigtop: command not found"), "{err}");
646    }
647
648    #[cfg(unix)]
649    #[test]
650    fn incompatible_protocol_is_rejected() {
651        let mut source = RemoteSource::new(RemoteTarget {
652            name: "old".to_owned(),
653            program: "sh".to_owned(),
654            args: vec![
655                "-c".to_owned(),
656                r#"echo '{"type":"hello","protocol":999,"version":"9.9.9"}'; sleep 5"#.to_owned(),
657            ],
658            fallback_args: Vec::new(),
659        });
660        let err = source.poll().unwrap_err().to_string();
661        assert!(err.contains("protocol 999"), "{err}");
662    }
663
664    #[cfg(unix)]
665    #[test]
666    fn old_agents_get_their_version_from_the_greeting() {
667        // An agent before 0.9.1: no version in its snapshots.
668        let mut source = RemoteSource::new(RemoteTarget {
669            name: "old".to_owned(),
670            program: "sh".to_owned(),
671            args: vec![
672                "-c".to_owned(),
673                r#"echo '{"type":"hello","protocol":1,"version":"0.8.2"}'
674                   echo '{"type":"snapshot","snapshot":{"host":{"hostname":"nas"}}}'
675                   sleep 5"#
676                    .to_owned(),
677            ],
678            fallback_args: Vec::new(),
679        });
680        let snapshot = source.poll().unwrap();
681        assert_eq!(snapshot.host.hostname, "nas");
682        assert_eq!(snapshot.host.rigtop_version, "0.8.2");
683        assert_eq!(
684            crate::view::version_note(&snapshot).as_deref(),
685            Some("agent 0.8.2")
686        );
687    }
688}