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::{AgentMessage, ClientMessage, PROTOCOL_VERSION, write_message};
17use crate::snapshot::Snapshot;
18use crate::source::{DataSource, ProcessSignal, SourceError};
19
20pub const DEFAULT_REMOTE_COMMAND: &str = "rigtop --agent";
22pub const FALLBACK_REMOTE_COMMAND: &str = "~/.cargo/bin/rigtop --agent";
26const NOT_FOUND_STATUS: i32 = 127;
28
29const RECONNECT_DELAY: Duration = Duration::from_secs(5);
31const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
34const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
36const STDERR_LINES: usize = 20;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct RemoteTarget {
42 pub name: String,
44 pub program: String,
46 pub args: Vec<String>,
48 pub fallback_args: Option<Vec<String>>,
51}
52
53impl RemoteTarget {
54 pub fn ssh(destination: &str, remote_command: &str) -> Self {
57 let options = [
58 "BatchMode=yes",
60 "ConnectTimeout=10",
61 "ServerAliveInterval=15",
63 "ServerAliveCountMax=3",
64 ];
65 let mut args = vec!["-T".to_owned()];
66 for option in options {
67 args.push("-o".to_owned());
68 args.push(option.to_owned());
69 }
70 args.push("--".to_owned());
72 args.push(destination.to_owned());
73 args.push(remote_command.to_owned());
74 let fallback_args = (remote_command == DEFAULT_REMOTE_COMMAND).then(|| {
77 let mut fallback = args.clone();
78 *fallback.last_mut().expect("command pushed above") =
79 FALLBACK_REMOTE_COMMAND.to_owned();
80 fallback
81 });
82 Self {
83 name: destination.to_owned(),
84 program: "ssh".to_owned(),
85 args,
86 fallback_args,
87 }
88 }
89}
90
91enum Incoming {
93 Message(AgentMessage),
94 Closed,
96}
97
98struct Connection {
100 child: Child,
101 stdin: ChildStdin,
102 incoming: Receiver<Incoming>,
103 stderr: Arc<Mutex<VecDeque<String>>>,
105 established: bool,
107}
108
109impl Connection {
110 fn spawn(target: &RemoteTarget, args: &[String]) -> Result<Self, SourceError> {
111 let mut command = Command::new(&target.program);
112 command
113 .args(args)
114 .stdin(Stdio::piped())
115 .stdout(Stdio::piped())
116 .stderr(Stdio::piped());
117 #[cfg(windows)]
118 {
119 use std::os::windows::process::CommandExt;
120 command.creation_flags(0x0800_0000);
122 }
123 let mut child = command.spawn().map_err(|err| {
124 SourceError::Disconnected(format!("cannot start {}: {err}", target.program))
125 })?;
126 let stdin = child.stdin.take().expect("piped stdin");
127 let stdout = child.stdout.take().expect("piped stdout");
128 let stderr_pipe = child.stderr.take().expect("piped stderr");
129
130 let (tx, incoming) = mpsc::channel();
131 thread::Builder::new()
132 .name("rigtop-remote-reader".to_owned())
133 .spawn(move || {
134 for line in BufReader::new(stdout).lines() {
135 let Ok(line) = line else { break };
136 if let Ok(message) = serde_json::from_str::<AgentMessage>(&line)
139 && tx.send(Incoming::Message(message)).is_err()
140 {
141 return;
142 }
143 }
144 let _ = tx.send(Incoming::Closed);
145 })
146 .map_err(SourceError::Io)?;
147
148 let stderr = Arc::new(Mutex::new(VecDeque::new()));
149 let lines = Arc::clone(&stderr);
150 thread::Builder::new()
151 .name("rigtop-remote-stderr".to_owned())
152 .spawn(move || {
153 for line in BufReader::new(stderr_pipe).lines() {
154 let Ok(line) = line else { break };
155 let mut lines = lines.lock().unwrap_or_else(|e| e.into_inner());
156 if lines.len() == STDERR_LINES {
157 lines.pop_front();
158 }
159 lines.push_back(line);
160 }
161 })
162 .map_err(SourceError::Io)?;
163
164 Ok(Self {
165 child,
166 stdin,
167 incoming,
168 stderr,
169 established: false,
170 })
171 }
172
173 fn send(&mut self, message: &ClientMessage) -> Result<(), SourceError> {
174 write_message(&mut self.stdin, message).map_err(SourceError::Io)
175 }
176
177 fn failure(&mut self) -> (String, bool) {
180 thread::sleep(Duration::from_millis(100));
182 let status = self.child.try_wait().ok().flatten();
183 let lines = self.stderr.lock().unwrap_or_else(|e| e.into_inner());
184 let detail = lines
185 .iter()
186 .rev()
187 .find(|l| !l.trim().is_empty())
188 .cloned()
189 .unwrap_or_default();
190 let not_found = status.and_then(|s| s.code()) == Some(NOT_FOUND_STATUS)
192 || detail.contains("command not found")
193 || detail.contains("not recognized");
194 let reason = match (detail.is_empty(), status) {
195 (false, _) => detail,
196 (true, Some(status)) => format!("connection closed ({status})"),
197 (true, None) => "connection closed".to_owned(),
198 };
199 (reason, not_found)
200 }
201}
202
203impl Drop for Connection {
204 fn drop(&mut self) {
205 let _ = self.child.kill();
208 let _ = self.child.wait();
209 }
210}
211
212pub struct RemoteSource {
214 target: RemoteTarget,
215 connection: Option<Connection>,
216 last_attempt: Option<Instant>,
217 interval: Duration,
218 process_limit: Option<usize>,
219 pending: VecDeque<Snapshot>,
221 agent_version: String,
223 last_error: Option<String>,
225 use_fallback: bool,
227 retry_now: bool,
229}
230
231impl RemoteSource {
232 pub fn new(target: RemoteTarget) -> Self {
234 Self {
235 target,
236 connection: None,
237 last_attempt: None,
238 interval: Duration::from_secs(1),
239 process_limit: None,
240 pending: VecDeque::new(),
241 agent_version: String::new(),
242 last_error: None,
243 use_fallback: false,
244 retry_now: false,
245 }
246 }
247
248 pub fn target(&self) -> &RemoteTarget {
250 &self.target
251 }
252
253 fn connection(&mut self) -> Result<&mut Connection, SourceError> {
255 if self.connection.is_none() {
256 if let Some(at) = self.last_attempt {
257 let wait = RECONNECT_DELAY.saturating_sub(at.elapsed());
258 if !wait.is_zero() {
259 let reason = self.last_error.as_deref().unwrap_or("not connected");
260 return Err(SourceError::Disconnected(format!(
261 "{}: {reason} (retrying in {}s)",
262 self.target.name,
263 wait.as_secs() + 1
264 )));
265 }
266 }
267 self.last_attempt = Some(Instant::now());
268 let args = match (&self.target.fallback_args, self.use_fallback) {
269 (Some(fallback), true) => fallback,
270 _ => &self.target.args,
271 };
272 let mut connection = match Connection::spawn(&self.target, args) {
273 Ok(connection) => connection,
274 Err(err) => {
275 self.last_error = Some(match &err {
276 SourceError::Disconnected(reason) => reason.clone(),
277 other => other.to_string(),
278 });
279 return Err(err);
280 }
281 };
282 let sent = connection
284 .send(&ClientMessage::SetInterval {
285 ms: self.interval.as_millis() as u64,
286 })
287 .and_then(|()| {
288 connection.send(&ClientMessage::SetProcessLimit {
289 limit: self.process_limit,
290 })
291 });
292 self.connection = Some(connection);
293 if sent.is_err() {
294 return Err(self.disconnect(None));
297 }
298 }
299 Ok(self.connection.as_mut().expect("connected above"))
300 }
301
302 fn disconnect(&mut self, reason: Option<String>) -> SourceError {
304 let (mut reason, not_found) = match (reason, self.connection.as_mut()) {
305 (Some(reason), _) => (reason, false),
306 (None, Some(connection)) => connection.failure(),
307 (None, None) => ("connection closed".to_owned(), false),
308 };
309 self.connection = None;
310 if not_found && self.target.fallback_args.is_some() {
311 if self.use_fallback {
312 self.use_fallback = false;
315 reason = format!("rigtop is neither on the PATH nor in ~/.cargo/bin ({reason})");
316 } else {
317 self.use_fallback = true;
318 self.retry_now = true;
319 self.last_attempt = None;
320 }
321 }
322 let error = SourceError::Disconnected(format!("{}: {reason}", self.target.name));
323 self.last_error = Some(reason);
324 error
325 }
326
327 fn next_message(&mut self, timeout: Duration) -> Result<Option<AgentMessage>, SourceError> {
329 let connection = self.connection()?;
330 match connection.incoming.recv_timeout(timeout) {
331 Ok(Incoming::Message(message)) => Ok(Some(message)),
332 Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => {
333 Err(self.disconnect(None))
334 }
335 Err(RecvTimeoutError::Timeout) => Ok(None),
336 }
337 }
338
339 fn absorb(&mut self, message: AgentMessage) -> Result<(), SourceError> {
342 match message {
343 AgentMessage::Hello { protocol, version } if protocol != PROTOCOL_VERSION => {
344 let reason = format!(
345 "agent speaks protocol {protocol} (rigtop {version}), this client speaks {PROTOCOL_VERSION}; \
346 install the same rigtop version on both sides"
347 );
348 self.last_attempt = Some(Instant::now());
350 Err(self.disconnect(Some(reason)))
351 }
352 AgentMessage::Hello { version, .. } => {
353 self.agent_version = version;
354 Ok(())
355 }
356 AgentMessage::SignalResult { .. } => Ok(()),
357 AgentMessage::Snapshot { mut snapshot } => {
358 if snapshot.host.rigtop_version.is_empty() {
360 snapshot.host.rigtop_version = self.agent_version.clone();
361 }
362 if let Some(connection) = self.connection.as_mut() {
363 connection.established = true;
364 }
365 self.last_error = None;
366 self.pending.push_back(*snapshot);
367 Ok(())
368 }
369 AgentMessage::Error { message } => Err(SourceError::Remote(message)),
370 }
371 }
372}
373
374impl RemoteSource {
375 fn poll_once(&mut self) -> Result<Snapshot, SourceError> {
377 let established = self.connection.as_ref().is_some_and(|c| c.established);
379 let timeout = if established {
380 self.interval * 3 + Duration::from_secs(5)
381 } else {
382 CONNECT_TIMEOUT
383 };
384 let deadline = Instant::now() + timeout;
385 loop {
386 while let Some(message) = self.next_message(Duration::ZERO)? {
388 self.absorb(message)?;
389 }
390 if let Some(newest) = self.pending.pop_back() {
391 self.pending.clear();
392 return Ok(newest);
393 }
394 let remaining = deadline.saturating_duration_since(Instant::now());
395 if remaining.is_zero() {
396 return Err(self.disconnect(Some("no data from the agent (timed out)".to_owned())));
397 }
398 if let Some(message) = self.next_message(remaining)? {
399 self.absorb(message)?;
400 }
401 }
402 }
403}
404
405impl DataSource for RemoteSource {
406 fn poll(&mut self) -> Result<Snapshot, SourceError> {
409 match self.poll_once() {
410 Err(_) if std::mem::take(&mut self.retry_now) => self.poll_once(),
412 result => result,
413 }
414 }
415
416 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
417 if self
418 .connection()?
419 .send(&ClientMessage::Signal { pid, signal })
420 .is_err()
421 {
422 return Err(self.disconnect(None));
423 }
424 let deadline = Instant::now() + SIGNAL_TIMEOUT;
425 loop {
426 let remaining = deadline.saturating_duration_since(Instant::now());
427 if remaining.is_zero() {
428 return Err(SourceError::Remote(format!(
429 "no answer to the signal for {pid}"
430 )));
431 }
432 match self.next_message(remaining)? {
433 Some(AgentMessage::SignalResult {
434 pid: answered,
435 error,
436 ..
437 }) if answered == pid => {
438 return match error {
439 None => Ok(()),
440 Some(error) => Err(SourceError::Remote(error)),
441 };
442 }
443 Some(other) => self.absorb(other)?,
444 None => {}
445 }
446 }
447 }
448
449 fn set_interval(&mut self, interval: Duration) {
450 self.interval = interval;
451 if let Some(connection) = self.connection.as_mut() {
452 let _ = connection.send(&ClientMessage::SetInterval {
453 ms: interval.as_millis() as u64,
454 });
455 }
456 }
457
458 fn set_process_limit(&mut self, limit: Option<usize>) {
459 self.process_limit = limit;
460 if let Some(connection) = self.connection.as_mut() {
461 let _ = connection.send(&ClientMessage::SetProcessLimit { limit });
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn ssh_target_uses_batch_mode_and_keeps_the_destination_safe() {
472 let target = RemoteTarget::ssh("zefat", DEFAULT_REMOTE_COMMAND);
473 assert_eq!(target.program, "ssh");
474 assert_eq!(target.name, "zefat");
475 assert!(target.args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
476 let n = target.args.len();
477 assert_eq!(&target.args[n - 3..], ["--", "zefat", "rigtop --agent"]);
478 let fallback = target.fallback_args.as_ref().unwrap();
480 assert_eq!(&fallback[n - 3..], ["--", "zefat", FALLBACK_REMOTE_COMMAND]);
481 let custom = RemoteTarget::ssh("zefat", "/opt/rigtop/bin/rigtop --agent");
483 assert_eq!(custom.fallback_args, None);
484 }
485
486 #[cfg(unix)]
488 fn sh_target(script: &str, fallback: Option<&str>) -> RemoteTarget {
489 let args = |script: &str| vec!["-c".to_owned(), script.to_owned()];
490 RemoteTarget {
491 name: "fake".to_owned(),
492 program: "sh".to_owned(),
493 args: args(script),
494 fallback_args: fallback.map(args),
495 }
496 }
497
498 #[cfg(unix)]
499 #[test]
500 fn command_not_found_tries_the_fallback_at_once() {
501 let missing = "echo 'zsh:1: command not found: rigtop' >&2; exit 127";
503 let mut source = RemoteSource::new(sh_target(missing, Some(missing)));
504 let err = source.poll().unwrap_err().to_string();
505 assert!(
506 err.contains("neither on the PATH nor in ~/.cargo/bin"),
507 "{err}"
508 );
509 assert!(!source.use_fallback, "the PATH comes first again next time");
510
511 let mut source = RemoteSource::new(sh_target(
513 "echo 'Permission denied (publickey)' >&2; exit 255",
514 Some(missing),
515 ));
516 let err = source.poll().unwrap_err().to_string();
517 assert!(err.contains("Permission denied"), "{err}");
518 assert!(!source.use_fallback);
519 }
520
521 #[test]
522 fn missing_program_is_reported_and_retried_later() {
523 let mut source = RemoteSource::new(RemoteTarget {
524 name: "nowhere".to_owned(),
525 program: "/nonexistent/ssh".to_owned(),
526 args: Vec::new(),
527 fallback_args: None,
528 });
529 let err = source.poll().unwrap_err().to_string();
530 assert!(err.contains("cannot start"), "{err}");
531 let err = source.poll().unwrap_err().to_string();
533 assert!(
534 err.contains("retrying") && err.contains("cannot start"),
535 "{err}"
536 );
537 }
538
539 #[cfg(unix)]
540 #[test]
541 fn stderr_explains_an_agent_that_exits() {
542 let mut source = RemoteSource::new(RemoteTarget {
544 name: "fake".to_owned(),
545 program: "sh".to_owned(),
546 args: vec![
547 "-c".to_owned(),
548 "echo 'rigtop: command not found' >&2; exit 127".to_owned(),
549 ],
550 fallback_args: None,
551 });
552 let err = source.poll().unwrap_err().to_string();
553 assert!(err.contains("fake: rigtop: command not found"), "{err}");
554 }
555
556 #[cfg(unix)]
557 #[test]
558 fn incompatible_protocol_is_rejected() {
559 let mut source = RemoteSource::new(RemoteTarget {
560 name: "old".to_owned(),
561 program: "sh".to_owned(),
562 args: vec![
563 "-c".to_owned(),
564 r#"echo '{"type":"hello","protocol":999,"version":"9.9.9"}'; sleep 5"#.to_owned(),
565 ],
566 fallback_args: None,
567 });
568 let err = source.poll().unwrap_err().to_string();
569 assert!(err.contains("protocol 999"), "{err}");
570 }
571
572 #[cfg(unix)]
573 #[test]
574 fn old_agents_get_their_version_from_the_greeting() {
575 let mut source = RemoteSource::new(RemoteTarget {
577 name: "old".to_owned(),
578 program: "sh".to_owned(),
579 args: vec![
580 "-c".to_owned(),
581 r#"echo '{"type":"hello","protocol":1,"version":"0.8.2"}'
582 echo '{"type":"snapshot","snapshot":{"host":{"hostname":"nas"}}}'
583 sleep 5"#
584 .to_owned(),
585 ],
586 fallback_args: None,
587 });
588 let snapshot = source.poll().unwrap();
589 assert_eq!(snapshot.host.hostname, "nas");
590 assert_eq!(snapshot.host.rigtop_version, "0.8.2");
591 assert_eq!(
592 crate::view::version_note(&snapshot).as_deref(),
593 Some("agent 0.8.2")
594 );
595 }
596}