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";
22
23const RECONNECT_DELAY: Duration = Duration::from_secs(5);
25const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
28const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
30const STDERR_LINES: usize = 20;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RemoteTarget {
36 pub name: String,
38 pub program: String,
40 pub args: Vec<String>,
42}
43
44impl RemoteTarget {
45 pub fn ssh(destination: &str, remote_command: &str) -> Self {
48 let options = [
49 "BatchMode=yes",
51 "ConnectTimeout=10",
52 "ServerAliveInterval=15",
54 "ServerAliveCountMax=3",
55 ];
56 let mut args = vec!["-T".to_owned()];
57 for option in options {
58 args.push("-o".to_owned());
59 args.push(option.to_owned());
60 }
61 args.push("--".to_owned());
63 args.push(destination.to_owned());
64 args.push(remote_command.to_owned());
65 Self {
66 name: destination.to_owned(),
67 program: "ssh".to_owned(),
68 args,
69 }
70 }
71}
72
73enum Incoming {
75 Message(AgentMessage),
76 Closed,
78}
79
80struct Connection {
82 child: Child,
83 stdin: ChildStdin,
84 incoming: Receiver<Incoming>,
85 stderr: Arc<Mutex<VecDeque<String>>>,
87 established: bool,
89}
90
91impl Connection {
92 fn spawn(target: &RemoteTarget) -> Result<Self, SourceError> {
93 let mut command = Command::new(&target.program);
94 command
95 .args(&target.args)
96 .stdin(Stdio::piped())
97 .stdout(Stdio::piped())
98 .stderr(Stdio::piped());
99 #[cfg(windows)]
100 {
101 use std::os::windows::process::CommandExt;
102 command.creation_flags(0x0800_0000);
104 }
105 let mut child = command.spawn().map_err(|err| {
106 SourceError::Disconnected(format!("cannot start {}: {err}", target.program))
107 })?;
108 let stdin = child.stdin.take().expect("piped stdin");
109 let stdout = child.stdout.take().expect("piped stdout");
110 let stderr_pipe = child.stderr.take().expect("piped stderr");
111
112 let (tx, incoming) = mpsc::channel();
113 thread::Builder::new()
114 .name("rigtop-remote-reader".to_owned())
115 .spawn(move || {
116 for line in BufReader::new(stdout).lines() {
117 let Ok(line) = line else { break };
118 if let Ok(message) = serde_json::from_str::<AgentMessage>(&line)
121 && tx.send(Incoming::Message(message)).is_err()
122 {
123 return;
124 }
125 }
126 let _ = tx.send(Incoming::Closed);
127 })
128 .map_err(SourceError::Io)?;
129
130 let stderr = Arc::new(Mutex::new(VecDeque::new()));
131 let lines = Arc::clone(&stderr);
132 thread::Builder::new()
133 .name("rigtop-remote-stderr".to_owned())
134 .spawn(move || {
135 for line in BufReader::new(stderr_pipe).lines() {
136 let Ok(line) = line else { break };
137 let mut lines = lines.lock().unwrap_or_else(|e| e.into_inner());
138 if lines.len() == STDERR_LINES {
139 lines.pop_front();
140 }
141 lines.push_back(line);
142 }
143 })
144 .map_err(SourceError::Io)?;
145
146 Ok(Self {
147 child,
148 stdin,
149 incoming,
150 stderr,
151 established: false,
152 })
153 }
154
155 fn send(&mut self, message: &ClientMessage) -> Result<(), SourceError> {
156 write_message(&mut self.stdin, message).map_err(SourceError::Io)
157 }
158
159 fn failure_reason(&mut self) -> String {
161 thread::sleep(Duration::from_millis(100));
163 let status = self.child.try_wait().ok().flatten();
164 let lines = self.stderr.lock().unwrap_or_else(|e| e.into_inner());
165 let detail = lines
166 .iter()
167 .rev()
168 .find(|l| !l.trim().is_empty())
169 .cloned()
170 .unwrap_or_default();
171 match (detail.is_empty(), status) {
172 (false, _) => detail,
173 (true, Some(status)) => format!("connection closed ({status})"),
174 (true, None) => "connection closed".to_owned(),
175 }
176 }
177}
178
179impl Drop for Connection {
180 fn drop(&mut self) {
181 let _ = self.child.kill();
184 let _ = self.child.wait();
185 }
186}
187
188pub struct RemoteSource {
190 target: RemoteTarget,
191 connection: Option<Connection>,
192 last_attempt: Option<Instant>,
193 interval: Duration,
194 process_limit: Option<usize>,
195 pending: VecDeque<Snapshot>,
197 last_error: Option<String>,
199}
200
201impl RemoteSource {
202 pub fn new(target: RemoteTarget) -> Self {
204 Self {
205 target,
206 connection: None,
207 last_attempt: None,
208 interval: Duration::from_secs(1),
209 process_limit: None,
210 pending: VecDeque::new(),
211 last_error: None,
212 }
213 }
214
215 pub fn target(&self) -> &RemoteTarget {
217 &self.target
218 }
219
220 fn connection(&mut self) -> Result<&mut Connection, SourceError> {
222 if self.connection.is_none() {
223 if let Some(at) = self.last_attempt {
224 let wait = RECONNECT_DELAY.saturating_sub(at.elapsed());
225 if !wait.is_zero() {
226 let reason = self.last_error.as_deref().unwrap_or("not connected");
227 return Err(SourceError::Disconnected(format!(
228 "{}: {reason} (retrying in {}s)",
229 self.target.name,
230 wait.as_secs() + 1
231 )));
232 }
233 }
234 self.last_attempt = Some(Instant::now());
235 let mut connection = match Connection::spawn(&self.target) {
236 Ok(connection) => connection,
237 Err(err) => {
238 self.last_error = Some(match &err {
239 SourceError::Disconnected(reason) => reason.clone(),
240 other => other.to_string(),
241 });
242 return Err(err);
243 }
244 };
245 let sent = connection
247 .send(&ClientMessage::SetInterval {
248 ms: self.interval.as_millis() as u64,
249 })
250 .and_then(|()| {
251 connection.send(&ClientMessage::SetProcessLimit {
252 limit: self.process_limit,
253 })
254 });
255 self.connection = Some(connection);
256 if sent.is_err() {
257 return Err(self.disconnect(None));
260 }
261 }
262 Ok(self.connection.as_mut().expect("connected above"))
263 }
264
265 fn disconnect(&mut self, reason: Option<String>) -> SourceError {
267 let reason = match (reason, self.connection.as_mut()) {
268 (Some(reason), _) => reason,
269 (None, Some(connection)) => connection.failure_reason(),
270 (None, None) => "connection closed".to_owned(),
271 };
272 self.connection = None;
273 let error = SourceError::Disconnected(format!("{}: {reason}", self.target.name));
274 self.last_error = Some(reason);
275 error
276 }
277
278 fn next_message(&mut self, timeout: Duration) -> Result<Option<AgentMessage>, SourceError> {
280 let connection = self.connection()?;
281 match connection.incoming.recv_timeout(timeout) {
282 Ok(Incoming::Message(message)) => Ok(Some(message)),
283 Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => {
284 Err(self.disconnect(None))
285 }
286 Err(RecvTimeoutError::Timeout) => Ok(None),
287 }
288 }
289
290 fn absorb(&mut self, message: AgentMessage) -> Result<(), SourceError> {
293 match message {
294 AgentMessage::Hello { protocol, version } if protocol != PROTOCOL_VERSION => {
295 let reason = format!(
296 "agent speaks protocol {protocol} (rigtop {version}), this client speaks {PROTOCOL_VERSION}; \
297 install the same rigtop version on both sides"
298 );
299 self.last_attempt = Some(Instant::now());
301 Err(self.disconnect(Some(reason)))
302 }
303 AgentMessage::Hello { .. } | AgentMessage::SignalResult { .. } => Ok(()),
304 AgentMessage::Snapshot { snapshot } => {
305 if let Some(connection) = self.connection.as_mut() {
306 connection.established = true;
307 }
308 self.last_error = None;
309 self.pending.push_back(*snapshot);
310 Ok(())
311 }
312 AgentMessage::Error { message } => Err(SourceError::Remote(message)),
313 }
314 }
315}
316
317impl DataSource for RemoteSource {
318 fn poll(&mut self) -> Result<Snapshot, SourceError> {
321 let established = self.connection.as_ref().is_some_and(|c| c.established);
323 let timeout = if established {
324 self.interval * 3 + Duration::from_secs(5)
325 } else {
326 CONNECT_TIMEOUT
327 };
328 let deadline = Instant::now() + timeout;
329 loop {
330 while let Some(message) = self.next_message(Duration::ZERO)? {
332 self.absorb(message)?;
333 }
334 if let Some(newest) = self.pending.pop_back() {
335 self.pending.clear();
336 return Ok(newest);
337 }
338 let remaining = deadline.saturating_duration_since(Instant::now());
339 if remaining.is_zero() {
340 return Err(self.disconnect(Some("no data from the agent (timed out)".to_owned())));
341 }
342 if let Some(message) = self.next_message(remaining)? {
343 self.absorb(message)?;
344 }
345 }
346 }
347
348 fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
349 if self
350 .connection()?
351 .send(&ClientMessage::Signal { pid, signal })
352 .is_err()
353 {
354 return Err(self.disconnect(None));
355 }
356 let deadline = Instant::now() + SIGNAL_TIMEOUT;
357 loop {
358 let remaining = deadline.saturating_duration_since(Instant::now());
359 if remaining.is_zero() {
360 return Err(SourceError::Remote(format!(
361 "no answer to the signal for {pid}"
362 )));
363 }
364 match self.next_message(remaining)? {
365 Some(AgentMessage::SignalResult {
366 pid: answered,
367 error,
368 ..
369 }) if answered == pid => {
370 return match error {
371 None => Ok(()),
372 Some(error) => Err(SourceError::Remote(error)),
373 };
374 }
375 Some(other) => self.absorb(other)?,
376 None => {}
377 }
378 }
379 }
380
381 fn set_interval(&mut self, interval: Duration) {
382 self.interval = interval;
383 if let Some(connection) = self.connection.as_mut() {
384 let _ = connection.send(&ClientMessage::SetInterval {
385 ms: interval.as_millis() as u64,
386 });
387 }
388 }
389
390 fn set_process_limit(&mut self, limit: Option<usize>) {
391 self.process_limit = limit;
392 if let Some(connection) = self.connection.as_mut() {
393 let _ = connection.send(&ClientMessage::SetProcessLimit { limit });
394 }
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn ssh_target_uses_batch_mode_and_keeps_the_destination_safe() {
404 let target = RemoteTarget::ssh("zefat", DEFAULT_REMOTE_COMMAND);
405 assert_eq!(target.program, "ssh");
406 assert_eq!(target.name, "zefat");
407 assert!(target.args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
408 let n = target.args.len();
409 assert_eq!(&target.args[n - 3..], ["--", "zefat", "rigtop --agent"]);
410 }
411
412 #[test]
413 fn missing_program_is_reported_and_retried_later() {
414 let mut source = RemoteSource::new(RemoteTarget {
415 name: "nowhere".to_owned(),
416 program: "/nonexistent/ssh".to_owned(),
417 args: Vec::new(),
418 });
419 let err = source.poll().unwrap_err().to_string();
420 assert!(err.contains("cannot start"), "{err}");
421 let err = source.poll().unwrap_err().to_string();
423 assert!(
424 err.contains("retrying") && err.contains("cannot start"),
425 "{err}"
426 );
427 }
428
429 #[cfg(unix)]
430 #[test]
431 fn stderr_explains_an_agent_that_exits() {
432 let mut source = RemoteSource::new(RemoteTarget {
434 name: "fake".to_owned(),
435 program: "sh".to_owned(),
436 args: vec![
437 "-c".to_owned(),
438 "echo 'rigtop: command not found' >&2; exit 127".to_owned(),
439 ],
440 });
441 let err = source.poll().unwrap_err().to_string();
442 assert!(err.contains("fake: rigtop: command not found"), "{err}");
443 }
444
445 #[cfg(unix)]
446 #[test]
447 fn incompatible_protocol_is_rejected() {
448 let mut source = RemoteSource::new(RemoteTarget {
449 name: "old".to_owned(),
450 program: "sh".to_owned(),
451 args: vec![
452 "-c".to_owned(),
453 r#"echo '{"type":"hello","protocol":999,"version":"9.9.9"}'; sleep 5"#.to_owned(),
454 ],
455 });
456 let err = source.poll().unwrap_err().to_string();
457 assert!(err.contains("protocol 999"), "{err}");
458 }
459}