use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use crate::agent::{AgentMessage, ClientMessage, PROTOCOL_VERSION, write_message};
use crate::snapshot::Snapshot;
use crate::source::{DataSource, ProcessSignal, SourceError};
pub const DEFAULT_REMOTE_COMMAND: &str = "rigtop --agent";
pub const FALLBACK_REMOTE_COMMAND: &str = "~/.cargo/bin/rigtop --agent";
const NOT_FOUND_STATUS: i32 = 127;
const RECONNECT_DELAY: Duration = Duration::from_secs(5);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
const SIGNAL_TIMEOUT: Duration = Duration::from_secs(10);
const STDERR_LINES: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteTarget {
pub name: String,
pub program: String,
pub args: Vec<String>,
pub fallback_args: Option<Vec<String>>,
}
impl RemoteTarget {
pub fn ssh(destination: &str, remote_command: &str) -> Self {
let options = [
"BatchMode=yes",
"ConnectTimeout=10",
"ServerAliveInterval=15",
"ServerAliveCountMax=3",
];
let mut args = vec!["-T".to_owned()];
for option in options {
args.push("-o".to_owned());
args.push(option.to_owned());
}
args.push("--".to_owned());
args.push(destination.to_owned());
args.push(remote_command.to_owned());
let fallback_args = (remote_command == DEFAULT_REMOTE_COMMAND).then(|| {
let mut fallback = args.clone();
*fallback.last_mut().expect("command pushed above") =
FALLBACK_REMOTE_COMMAND.to_owned();
fallback
});
Self {
name: destination.to_owned(),
program: "ssh".to_owned(),
args,
fallback_args,
}
}
}
enum Incoming {
Message(AgentMessage),
Closed,
}
struct Connection {
child: Child,
stdin: ChildStdin,
incoming: Receiver<Incoming>,
stderr: Arc<Mutex<VecDeque<String>>>,
established: bool,
}
impl Connection {
fn spawn(target: &RemoteTarget, args: &[String]) -> Result<Self, SourceError> {
let mut command = Command::new(&target.program);
command
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x0800_0000);
}
let mut child = command.spawn().map_err(|err| {
SourceError::Disconnected(format!("cannot start {}: {err}", target.program))
})?;
let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
let stderr_pipe = child.stderr.take().expect("piped stderr");
let (tx, incoming) = mpsc::channel();
thread::Builder::new()
.name("rigtop-remote-reader".to_owned())
.spawn(move || {
for line in BufReader::new(stdout).lines() {
let Ok(line) = line else { break };
if let Ok(message) = serde_json::from_str::<AgentMessage>(&line)
&& tx.send(Incoming::Message(message)).is_err()
{
return;
}
}
let _ = tx.send(Incoming::Closed);
})
.map_err(SourceError::Io)?;
let stderr = Arc::new(Mutex::new(VecDeque::new()));
let lines = Arc::clone(&stderr);
thread::Builder::new()
.name("rigtop-remote-stderr".to_owned())
.spawn(move || {
for line in BufReader::new(stderr_pipe).lines() {
let Ok(line) = line else { break };
let mut lines = lines.lock().unwrap_or_else(|e| e.into_inner());
if lines.len() == STDERR_LINES {
lines.pop_front();
}
lines.push_back(line);
}
})
.map_err(SourceError::Io)?;
Ok(Self {
child,
stdin,
incoming,
stderr,
established: false,
})
}
fn send(&mut self, message: &ClientMessage) -> Result<(), SourceError> {
write_message(&mut self.stdin, message).map_err(SourceError::Io)
}
fn failure(&mut self) -> (String, bool) {
thread::sleep(Duration::from_millis(100));
let status = self.child.try_wait().ok().flatten();
let lines = self.stderr.lock().unwrap_or_else(|e| e.into_inner());
let detail = lines
.iter()
.rev()
.find(|l| !l.trim().is_empty())
.cloned()
.unwrap_or_default();
let not_found = status.and_then(|s| s.code()) == Some(NOT_FOUND_STATUS)
|| detail.contains("command not found")
|| detail.contains("not recognized");
let reason = match (detail.is_empty(), status) {
(false, _) => detail,
(true, Some(status)) => format!("connection closed ({status})"),
(true, None) => "connection closed".to_owned(),
};
(reason, not_found)
}
}
impl Drop for Connection {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
pub struct RemoteSource {
target: RemoteTarget,
connection: Option<Connection>,
last_attempt: Option<Instant>,
interval: Duration,
process_limit: Option<usize>,
pending: VecDeque<Snapshot>,
last_error: Option<String>,
use_fallback: bool,
retry_now: bool,
}
impl RemoteSource {
pub fn new(target: RemoteTarget) -> Self {
Self {
target,
connection: None,
last_attempt: None,
interval: Duration::from_secs(1),
process_limit: None,
pending: VecDeque::new(),
last_error: None,
use_fallback: false,
retry_now: false,
}
}
pub fn target(&self) -> &RemoteTarget {
&self.target
}
fn connection(&mut self) -> Result<&mut Connection, SourceError> {
if self.connection.is_none() {
if let Some(at) = self.last_attempt {
let wait = RECONNECT_DELAY.saturating_sub(at.elapsed());
if !wait.is_zero() {
let reason = self.last_error.as_deref().unwrap_or("not connected");
return Err(SourceError::Disconnected(format!(
"{}: {reason} (retrying in {}s)",
self.target.name,
wait.as_secs() + 1
)));
}
}
self.last_attempt = Some(Instant::now());
let args = match (&self.target.fallback_args, self.use_fallback) {
(Some(fallback), true) => fallback,
_ => &self.target.args,
};
let mut connection = match Connection::spawn(&self.target, args) {
Ok(connection) => connection,
Err(err) => {
self.last_error = Some(match &err {
SourceError::Disconnected(reason) => reason.clone(),
other => other.to_string(),
});
return Err(err);
}
};
let sent = connection
.send(&ClientMessage::SetInterval {
ms: self.interval.as_millis() as u64,
})
.and_then(|()| {
connection.send(&ClientMessage::SetProcessLimit {
limit: self.process_limit,
})
});
self.connection = Some(connection);
if sent.is_err() {
return Err(self.disconnect(None));
}
}
Ok(self.connection.as_mut().expect("connected above"))
}
fn disconnect(&mut self, reason: Option<String>) -> SourceError {
let (mut reason, not_found) = match (reason, self.connection.as_mut()) {
(Some(reason), _) => (reason, false),
(None, Some(connection)) => connection.failure(),
(None, None) => ("connection closed".to_owned(), false),
};
self.connection = None;
if not_found && self.target.fallback_args.is_some() {
if self.use_fallback {
self.use_fallback = false;
reason = format!("rigtop is neither on the PATH nor in ~/.cargo/bin ({reason})");
} else {
self.use_fallback = true;
self.retry_now = true;
self.last_attempt = None;
}
}
let error = SourceError::Disconnected(format!("{}: {reason}", self.target.name));
self.last_error = Some(reason);
error
}
fn next_message(&mut self, timeout: Duration) -> Result<Option<AgentMessage>, SourceError> {
let connection = self.connection()?;
match connection.incoming.recv_timeout(timeout) {
Ok(Incoming::Message(message)) => Ok(Some(message)),
Ok(Incoming::Closed) | Err(RecvTimeoutError::Disconnected) => {
Err(self.disconnect(None))
}
Err(RecvTimeoutError::Timeout) => Ok(None),
}
}
fn absorb(&mut self, message: AgentMessage) -> Result<(), SourceError> {
match message {
AgentMessage::Hello { protocol, version } if protocol != PROTOCOL_VERSION => {
let reason = format!(
"agent speaks protocol {protocol} (rigtop {version}), this client speaks {PROTOCOL_VERSION}; \
install the same rigtop version on both sides"
);
self.last_attempt = Some(Instant::now());
Err(self.disconnect(Some(reason)))
}
AgentMessage::Hello { .. } | AgentMessage::SignalResult { .. } => Ok(()),
AgentMessage::Snapshot { snapshot } => {
if let Some(connection) = self.connection.as_mut() {
connection.established = true;
}
self.last_error = None;
self.pending.push_back(*snapshot);
Ok(())
}
AgentMessage::Error { message } => Err(SourceError::Remote(message)),
}
}
}
impl RemoteSource {
fn poll_once(&mut self) -> Result<Snapshot, SourceError> {
let established = self.connection.as_ref().is_some_and(|c| c.established);
let timeout = if established {
self.interval * 3 + Duration::from_secs(5)
} else {
CONNECT_TIMEOUT
};
let deadline = Instant::now() + timeout;
loop {
while let Some(message) = self.next_message(Duration::ZERO)? {
self.absorb(message)?;
}
if let Some(newest) = self.pending.pop_back() {
self.pending.clear();
return Ok(newest);
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(self.disconnect(Some("no data from the agent (timed out)".to_owned())));
}
if let Some(message) = self.next_message(remaining)? {
self.absorb(message)?;
}
}
}
}
impl DataSource for RemoteSource {
fn poll(&mut self) -> Result<Snapshot, SourceError> {
match self.poll_once() {
Err(_) if std::mem::take(&mut self.retry_now) => self.poll_once(),
result => result,
}
}
fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
if self
.connection()?
.send(&ClientMessage::Signal { pid, signal })
.is_err()
{
return Err(self.disconnect(None));
}
let deadline = Instant::now() + SIGNAL_TIMEOUT;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(SourceError::Remote(format!(
"no answer to the signal for {pid}"
)));
}
match self.next_message(remaining)? {
Some(AgentMessage::SignalResult {
pid: answered,
error,
..
}) if answered == pid => {
return match error {
None => Ok(()),
Some(error) => Err(SourceError::Remote(error)),
};
}
Some(other) => self.absorb(other)?,
None => {}
}
}
}
fn set_interval(&mut self, interval: Duration) {
self.interval = interval;
if let Some(connection) = self.connection.as_mut() {
let _ = connection.send(&ClientMessage::SetInterval {
ms: interval.as_millis() as u64,
});
}
}
fn set_process_limit(&mut self, limit: Option<usize>) {
self.process_limit = limit;
if let Some(connection) = self.connection.as_mut() {
let _ = connection.send(&ClientMessage::SetProcessLimit { limit });
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssh_target_uses_batch_mode_and_keeps_the_destination_safe() {
let target = RemoteTarget::ssh("zefat", DEFAULT_REMOTE_COMMAND);
assert_eq!(target.program, "ssh");
assert_eq!(target.name, "zefat");
assert!(target.args.windows(2).any(|w| w == ["-o", "BatchMode=yes"]));
let n = target.args.len();
assert_eq!(&target.args[n - 3..], ["--", "zefat", "rigtop --agent"]);
let fallback = target.fallback_args.as_ref().unwrap();
assert_eq!(&fallback[n - 3..], ["--", "zefat", FALLBACK_REMOTE_COMMAND]);
let custom = RemoteTarget::ssh("zefat", "/opt/rigtop/bin/rigtop --agent");
assert_eq!(custom.fallback_args, None);
}
#[cfg(unix)]
fn sh_target(script: &str, fallback: Option<&str>) -> RemoteTarget {
let args = |script: &str| vec!["-c".to_owned(), script.to_owned()];
RemoteTarget {
name: "fake".to_owned(),
program: "sh".to_owned(),
args: args(script),
fallback_args: fallback.map(args),
}
}
#[cfg(unix)]
#[test]
fn command_not_found_tries_the_fallback_at_once() {
let missing = "echo 'zsh:1: command not found: rigtop' >&2; exit 127";
let mut source = RemoteSource::new(sh_target(missing, Some(missing)));
let err = source.poll().unwrap_err().to_string();
assert!(
err.contains("neither on the PATH nor in ~/.cargo/bin"),
"{err}"
);
assert!(!source.use_fallback, "the PATH comes first again next time");
let mut source = RemoteSource::new(sh_target(
"echo 'Permission denied (publickey)' >&2; exit 255",
Some(missing),
));
let err = source.poll().unwrap_err().to_string();
assert!(err.contains("Permission denied"), "{err}");
assert!(!source.use_fallback);
}
#[test]
fn missing_program_is_reported_and_retried_later() {
let mut source = RemoteSource::new(RemoteTarget {
name: "nowhere".to_owned(),
program: "/nonexistent/ssh".to_owned(),
args: Vec::new(),
fallback_args: None,
});
let err = source.poll().unwrap_err().to_string();
assert!(err.contains("cannot start"), "{err}");
let err = source.poll().unwrap_err().to_string();
assert!(
err.contains("retrying") && err.contains("cannot start"),
"{err}"
);
}
#[cfg(unix)]
#[test]
fn stderr_explains_an_agent_that_exits() {
let mut source = RemoteSource::new(RemoteTarget {
name: "fake".to_owned(),
program: "sh".to_owned(),
args: vec![
"-c".to_owned(),
"echo 'rigtop: command not found' >&2; exit 127".to_owned(),
],
fallback_args: None,
});
let err = source.poll().unwrap_err().to_string();
assert!(err.contains("fake: rigtop: command not found"), "{err}");
}
#[cfg(unix)]
#[test]
fn incompatible_protocol_is_rejected() {
let mut source = RemoteSource::new(RemoteTarget {
name: "old".to_owned(),
program: "sh".to_owned(),
args: vec![
"-c".to_owned(),
r#"echo '{"type":"hello","protocol":999,"version":"9.9.9"}'; sleep 5"#.to_owned(),
],
fallback_args: None,
});
let err = source.poll().unwrap_err().to_string();
assert!(err.contains("protocol 999"), "{err}");
}
}