1use 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_WATCH_PROCESSES, PROTOCOL_VERSION, write_message,
18};
19use crate::snapshot::Snapshot;
20use crate::source::{DataSource, ProcessSignal, SourceError};
21
22pub const DEFAULT_REMOTE_COMMAND: &str = "rigtop --agent";
24pub const FALLBACK_REMOTE_COMMANDS: [&str; 2] =
29 ["~/.local/bin/rigtop --agent", "~/.cargo/bin/rigtop --agent"];
30const NOT_FOUND_STATUS: i32 = 127;
32
33const RECONNECT_DELAY: Duration = Duration::from_secs(5);
35const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
38const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
40const STDERR_LINES: usize = 20;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct RemoteTarget {
46 pub name: String,
48 pub program: String,
50 pub args: Vec<String>,
52 pub fallback_args: Vec<Vec<String>>,
55}
56
57impl RemoteTarget {
58 pub fn ssh(destination: &str, remote_command: &str) -> Self {
61 let options = [
62 "BatchMode=yes",
64 "ConnectTimeout=10",
65 "ServerAliveInterval=15",
67 "ServerAliveCountMax=3",
68 ];
69 let mut args = vec!["-T".to_owned()];
70 for option in options {
71 args.push("-o".to_owned());
72 args.push(option.to_owned());
73 }
74 args.push("--".to_owned());
76 args.push(destination.to_owned());
77 args.push(remote_command.to_owned());
78 let fallback_args = if remote_command == DEFAULT_REMOTE_COMMAND {
81 FALLBACK_REMOTE_COMMANDS
82 .iter()
83 .map(|command| {
84 let mut fallback = args.clone();
85 *fallback.last_mut().expect("command pushed above") = (*command).to_owned();
86 fallback
87 })
88 .collect()
89 } else {
90 Vec::new()
91 };
92 Self {
93 name: destination.to_owned(),
94 program: "ssh".to_owned(),
95 args,
96 fallback_args,
97 }
98 }
99}
100
101enum Incoming {
103 Message(AgentMessage),
104 Closed,
106}
107
108struct Connection {
110 child: Child,
111 stdin: ChildStdin,
112 incoming: Receiver<Incoming>,
113 stderr: Arc<Mutex<VecDeque<String>>>,
115 established: bool,
117}
118
119impl Connection {
120 fn spawn(target: &RemoteTarget, args: &[String]) -> Result<Self, SourceError> {
121 let mut command = Command::new(&target.program);
122 command
123 .args(args)
124 .stdin(Stdio::piped())
125 .stdout(Stdio::piped())
126 .stderr(Stdio::piped());
127 #[cfg(windows)]
128 {
129 use std::os::windows::process::CommandExt;
130 command.creation_flags(0x0800_0000);
132 }
133 let mut child = command.spawn().map_err(|err| {
134 SourceError::Disconnected(format!("cannot start {}: {err}", target.program))
135 })?;
136 let stdin = child.stdin.take().expect("piped stdin");
137 let stdout = child.stdout.take().expect("piped stdout");
138 let stderr_pipe = child.stderr.take().expect("piped stderr");
139
140 let (tx, incoming) = mpsc::channel();
141 thread::Builder::new()
142 .name("rigtop-remote-reader".to_owned())
143 .spawn(move || {
144 for line in BufReader::new(stdout).lines() {
145 let Ok(line) = line else { break };
146 if let Ok(message) = serde_json::from_str::<AgentMessage>(&line)
149 && tx.send(Incoming::Message(message)).is_err()
150 {
151 return;
152 }
153 }
154 let _ = tx.send(Incoming::Closed);
155 })
156 .map_err(SourceError::Io)?;
157
158 let stderr = Arc::new(Mutex::new(VecDeque::new()));
159 let lines = Arc::clone(&stderr);
160 thread::Builder::new()
161 .name("rigtop-remote-stderr".to_owned())
162 .spawn(move || {
163 for line in BufReader::new(stderr_pipe).lines() {
164 let Ok(line) = line else { break };
165 let mut lines = lines.lock().unwrap_or_else(|e| e.into_inner());
166 if lines.len() == STDERR_LINES {
167 lines.pop_front();
168 }
169 lines.push_back(line);
170 }
171 })
172 .map_err(SourceError::Io)?;
173
174 Ok(Self {
175 child,
176 stdin,
177 incoming,
178 stderr,
179 established: false,
180 })
181 }
182
183 fn send(&mut self, message: &ClientMessage) -> Result<(), SourceError> {
184 write_message(&mut self.stdin, message).map_err(SourceError::Io)
185 }
186
187 fn failure(&mut self) -> (String, bool) {
190 thread::sleep(Duration::from_millis(100));
192 let status = self.child.try_wait().ok().flatten();
193 let lines = self.stderr.lock().unwrap_or_else(|e| e.into_inner());
194 let detail = lines
195 .iter()
196 .rev()
197 .find(|l| !l.trim().is_empty())
198 .cloned()
199 .unwrap_or_default();
200 let not_found = status.and_then(|s| s.code()) == Some(NOT_FOUND_STATUS)
202 || detail.contains("command not found")
203 || detail.contains("not recognized");
204 let reason = match (detail.is_empty(), status) {
205 (false, _) => detail,
206 (true, Some(status)) => format!("connection closed ({status})"),
207 (true, None) => "connection closed".to_owned(),
208 };
209 (reason, not_found)
210 }
211}
212
213impl Drop for Connection {
214 fn drop(&mut self) {
215 let _ = self.child.kill();
218 let _ = self.child.wait();
219 }
220}
221
222pub struct RemoteSource {
224 target: RemoteTarget,
225 connection: Option<Connection>,
226 last_attempt: Option<Instant>,
227 interval: Duration,
228 process_limit: Option<usize>,
229 watched_pids: Vec<u32>,
231 watched_names: Vec<String>,
233 agent_watches: bool,
235 pending: VecDeque<Snapshot>,
237 agent_version: String,
239 last_error: Option<String>,
241 attempt: usize,
244 retry_now: bool,
246}
247
248impl RemoteSource {
249 pub fn new(target: RemoteTarget) -> Self {
251 Self {
252 target,
253 connection: None,
254 last_attempt: None,
255 interval: Duration::from_secs(1),
256 process_limit: None,
257 watched_pids: Vec::new(),
258 watched_names: Vec::new(),
259 agent_watches: false,
260 pending: VecDeque::new(),
261 agent_version: String::new(),
262 last_error: None,
263 attempt: 0,
264 retry_now: false,
265 }
266 }
267
268 pub fn target(&self) -> &RemoteTarget {
270 &self.target
271 }
272
273 fn connection(&mut self) -> Result<&mut Connection, SourceError> {
275 if self.connection.is_none() {
276 if let Some(at) = self.last_attempt {
277 let wait = RECONNECT_DELAY.saturating_sub(at.elapsed());
278 if !wait.is_zero() {
279 let reason = self.last_error.as_deref().unwrap_or("not connected");
280 return Err(SourceError::Disconnected(format!(
281 "{}: {reason} (retrying in {}s)",
282 self.target.name,
283 wait.as_secs() + 1
284 )));
285 }
286 }
287 self.last_attempt = Some(Instant::now());
288 let args = match self.attempt {
289 0 => &self.target.args,
290 n => self
291 .target
292 .fallback_args
293 .get(n - 1)
294 .unwrap_or(&self.target.args),
295 };
296 let mut connection = match Connection::spawn(&self.target, args) {
297 Ok(connection) => connection,
298 Err(err) => {
299 self.last_error = Some(match &err {
300 SourceError::Disconnected(reason) => reason.clone(),
301 other => other.to_string(),
302 });
303 return Err(err);
304 }
305 };
306 self.agent_watches = false;
308 let sent = connection
310 .send(&ClientMessage::SetInterval {
311 ms: self.interval.as_millis() as u64,
312 })
313 .and_then(|()| {
314 connection.send(&ClientMessage::SetProcessLimit {
315 limit: self.process_limit,
316 })
317 });
318 self.connection = Some(connection);
319 if sent.is_err() {
320 return Err(self.disconnect(None));
323 }
324 }
325 Ok(self.connection.as_mut().expect("connected above"))
326 }
327
328 fn disconnect(&mut self, reason: Option<String>) -> SourceError {
330 let (mut reason, not_found) = match (reason, self.connection.as_mut()) {
331 (Some(reason), _) => (reason, false),
332 (None, Some(connection)) => connection.failure(),
333 (None, None) => ("connection closed".to_owned(), false),
334 };
335 self.connection = None;
336 if not_found && !self.target.fallback_args.is_empty() {
337 if self.attempt < self.target.fallback_args.len() {
338 self.attempt += 1;
339 self.retry_now = true;
340 self.last_attempt = None;
341 } else {
342 self.attempt = 0;
345 reason = format!(
346 "rigtop is not on the PATH, in ~/.local/bin or in ~/.cargo/bin ({reason})"
347 );
348 }
349 }
350 let error = SourceError::Disconnected(format!("{}: {reason}", self.target.name));
351 self.last_error = Some(reason);
352 error
353 }
354
355 fn next_message(&mut self, timeout: Duration) -> Result<Option<AgentMessage>, SourceError> {
357 let connection = self.connection()?;
358 match connection.incoming.recv_timeout(timeout) {
359 Ok(Incoming::Message(message)) => Ok(Some(message)),
360 Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => {
361 Err(self.disconnect(None))
362 }
363 Err(RecvTimeoutError::Timeout) => Ok(None),
364 }
365 }
366
367 fn send_watched(&mut self) {
371 if let Some(connection) = self.connection.as_mut() {
372 let _ = connection.send(&ClientMessage::WatchProcesses {
373 pids: self.watched_pids.clone(),
374 names: self.watched_names.clone(),
376 });
377 }
378 }
379
380 fn absorb(&mut self, message: AgentMessage) -> Result<(), SourceError> {
381 match message {
382 AgentMessage::Hello {
383 protocol, version, ..
384 } if protocol != PROTOCOL_VERSION => {
385 let reason = format!(
386 "agent speaks protocol {protocol} (rigtop {version}), this client speaks {PROTOCOL_VERSION}; \
387 install the same rigtop version on both sides"
388 );
389 self.last_attempt = Some(Instant::now());
391 Err(self.disconnect(Some(reason)))
392 }
393 AgentMessage::Hello {
394 version, features, ..
395 } => {
396 self.agent_version = version;
397 self.agent_watches = features.iter().any(|f| f == FEATURE_WATCH_PROCESSES);
398 if self.agent_watches
399 && !(self.watched_pids.is_empty() && self.watched_names.is_empty())
400 {
401 self.send_watched();
402 }
403 Ok(())
404 }
405 AgentMessage::SignalResult { .. } => Ok(()),
406 AgentMessage::Snapshot { mut snapshot } => {
407 if snapshot.host.rigtop_version.is_empty() {
409 snapshot.host.rigtop_version = self.agent_version.clone();
410 }
411 if let Some(connection) = self.connection.as_mut() {
412 connection.established = true;
413 }
414 self.last_error = None;
415 self.pending.push_back(*snapshot);
416 Ok(())
417 }
418 AgentMessage::Error { message } => Err(SourceError::Remote(message)),
419 }
420 }
421}
422
423impl RemoteSource {
424 fn poll_once(&mut self) -> Result<Snapshot, SourceError> {
426 let established = self.connection.as_ref().is_some_and(|c| c.established);
428 let timeout = if established {
429 self.interval * 3 + Duration::from_secs(5)
430 } else {
431 CONNECT_TIMEOUT
432 };
433 let deadline = Instant::now() + timeout;
434 loop {
435 while let Some(message) = self.next_message(Duration::ZERO)? {
437 self.absorb(message)?;
438 }
439 if let Some(newest) = self.pending.pop_back() {
440 self.pending.clear();
441 return Ok(newest);
442 }
443 let remaining = deadline.saturating_duration_since(Instant::now());
444 if remaining.is_zero() {
445 return Err(self.disconnect(Some("no data from the agent (timed out)".to_owned())));
446 }
447 if let Some(message) = self.next_message(remaining)? {
448 self.absorb(message)?;
449 }
450 }
451 }
452}
453
454impl DataSource for RemoteSource {
455 fn poll(&mut self) -> Result<Snapshot, SourceError> {
458 loop {
459 match self.poll_once() {
460 Err(_) if std::mem::take(&mut self.retry_now) => continue,
463 result => return result,
464 }
465 }
466 }
467
468 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
469 if self
470 .connection()?
471 .send(&ClientMessage::Signal { pid, signal })
472 .is_err()
473 {
474 return Err(self.disconnect(None));
475 }
476 let deadline = Instant::now() + SIGNAL_TIMEOUT;
477 loop {
478 let remaining = deadline.saturating_duration_since(Instant::now());
479 if remaining.is_zero() {
480 return Err(SourceError::Remote(format!(
481 "no answer to the signal for {pid}"
482 )));
483 }
484 match self.next_message(remaining)? {
485 Some(AgentMessage::SignalResult {
486 pid: answered,
487 error,
488 ..
489 }) if answered == pid => {
490 return match error {
491 None => Ok(()),
492 Some(error) => Err(SourceError::Remote(error)),
493 };
494 }
495 Some(other) => self.absorb(other)?,
496 None => {}
497 }
498 }
499 }
500
501 fn set_interval(&mut self, interval: Duration) {
502 self.interval = interval;
503 if let Some(connection) = self.connection.as_mut() {
504 let _ = connection.send(&ClientMessage::SetInterval {
505 ms: interval.as_millis() as u64,
506 });
507 }
508 }
509
510 fn set_process_limit(&mut self, limit: Option<usize>) {
511 self.process_limit = limit;
512 if let Some(connection) = self.connection.as_mut() {
513 let _ = connection.send(&ClientMessage::SetProcessLimit { limit });
514 }
515 }
516
517 fn watch_processes(&mut self, pids: Vec<u32>) {
518 self.watched_pids = pids;
519 if self.agent_watches {
522 self.send_watched();
523 }
524 }
525
526 fn watch_process_names(&mut self, names: Vec<String>) {
527 self.watched_names = names;
528 if self.agent_watches {
529 self.send_watched();
530 }
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[test]
539 fn ssh_target_uses_batch_mode_and_keeps_the_destination_safe() {
540 let target = RemoteTarget::ssh("zefat", DEFAULT_REMOTE_COMMAND);
541 assert_eq!(target.program, "ssh");
542 assert_eq!(target.name, "zefat");
543 assert!(target.args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
544 let n = target.args.len();
545 assert_eq!(&target.args[n - 3..], ["--", "zefat", "rigtop --agent"]);
546 let fallbacks: Vec<&str> = target
548 .fallback_args
549 .iter()
550 .map(|args| args[n - 1].as_str())
551 .collect();
552 assert_eq!(fallbacks, FALLBACK_REMOTE_COMMANDS);
553 assert_eq!(&target.fallback_args[0][n - 3..n - 1], ["--", "zefat"]);
554 let custom = RemoteTarget::ssh("zefat", "/opt/rigtop/bin/rigtop --agent");
556 assert!(custom.fallback_args.is_empty());
557 }
558
559 #[cfg(unix)]
561 fn sh_target(script: &str, fallbacks: &[&str]) -> RemoteTarget {
562 let args = |script: &str| vec!["-c".to_owned(), script.to_owned()];
563 RemoteTarget {
564 name: "fake".to_owned(),
565 program: "sh".to_owned(),
566 args: args(script),
567 fallback_args: fallbacks.iter().map(|script| args(script)).collect(),
568 }
569 }
570
571 #[cfg(unix)]
572 #[test]
573 fn command_not_found_tries_the_fallback_at_once() {
574 let missing = "echo 'zsh:1: command not found: rigtop' >&2; exit 127";
576 let mut source = RemoteSource::new(sh_target(missing, &[missing, missing]));
577 let err = source.poll().unwrap_err().to_string();
578 assert!(
579 err.contains("not on the PATH, in ~/.local/bin or in ~/.cargo/bin"),
580 "{err}"
581 );
582 assert_eq!(source.attempt, 0, "the PATH comes first again next time");
583
584 let mut source = RemoteSource::new(sh_target(
586 "echo 'Permission denied (publickey)' >&2; exit 255",
587 &[missing],
588 ));
589 let err = source.poll().unwrap_err().to_string();
590 assert!(err.contains("Permission denied"), "{err}");
591 assert_eq!(source.attempt, 0);
592 }
593
594 #[test]
595 fn missing_program_is_reported_and_retried_later() {
596 let mut source = RemoteSource::new(RemoteTarget {
597 name: "nowhere".to_owned(),
598 program: "/nonexistent/ssh".to_owned(),
599 args: Vec::new(),
600 fallback_args: Vec::new(),
601 });
602 let err = source.poll().unwrap_err().to_string();
603 assert!(err.contains("cannot start"), "{err}");
604 let err = source.poll().unwrap_err().to_string();
606 assert!(
607 err.contains("retrying") && err.contains("cannot start"),
608 "{err}"
609 );
610 }
611
612 #[cfg(unix)]
613 #[test]
614 fn stderr_explains_an_agent_that_exits() {
615 let mut source = RemoteSource::new(RemoteTarget {
617 name: "fake".to_owned(),
618 program: "sh".to_owned(),
619 args: vec![
620 "-c".to_owned(),
621 "echo 'rigtop: command not found' >&2; exit 127".to_owned(),
622 ],
623 fallback_args: Vec::new(),
624 });
625 let err = source.poll().unwrap_err().to_string();
626 assert!(err.contains("fake: rigtop: command not found"), "{err}");
627 }
628
629 #[cfg(unix)]
630 #[test]
631 fn incompatible_protocol_is_rejected() {
632 let mut source = RemoteSource::new(RemoteTarget {
633 name: "old".to_owned(),
634 program: "sh".to_owned(),
635 args: vec![
636 "-c".to_owned(),
637 r#"echo '{"type":"hello","protocol":999,"version":"9.9.9"}'; sleep 5"#.to_owned(),
638 ],
639 fallback_args: Vec::new(),
640 });
641 let err = source.poll().unwrap_err().to_string();
642 assert!(err.contains("protocol 999"), "{err}");
643 }
644
645 #[cfg(unix)]
646 #[test]
647 fn old_agents_get_their_version_from_the_greeting() {
648 let mut source = RemoteSource::new(RemoteTarget {
650 name: "old".to_owned(),
651 program: "sh".to_owned(),
652 args: vec![
653 "-c".to_owned(),
654 r#"echo '{"type":"hello","protocol":1,"version":"0.8.2"}'
655 echo '{"type":"snapshot","snapshot":{"host":{"hostname":"nas"}}}'
656 sleep 5"#
657 .to_owned(),
658 ],
659 fallback_args: Vec::new(),
660 });
661 let snapshot = source.poll().unwrap();
662 assert_eq!(snapshot.host.hostname, "nas");
663 assert_eq!(snapshot.host.rigtop_version, "0.8.2");
664 assert_eq!(
665 crate::view::version_note(&snapshot).as_deref(),
666 Some("agent 0.8.2")
667 );
668 }
669}