use std::io::{self, BufRead, Write};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::snapshot::Snapshot;
use crate::source::{DataSource, MIN_POLL_INTERVAL, ProcessSignal};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentMessage {
Hello {
protocol: u32,
version: String,
},
Snapshot {
snapshot: Box<Snapshot>,
},
SignalResult {
pid: u32,
signal: ProcessSignal,
error: Option<String>,
},
Error { message: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
SetInterval { ms: u64 },
SetProcessLimit { limit: Option<usize> },
Signal { pid: u32, signal: ProcessSignal },
}
pub fn write_message<W: Write, M: Serialize>(writer: &mut W, message: &M) -> io::Result<()> {
serde_json::to_writer(&mut *writer, message)?;
writer.write_all(b"\n")?;
writer.flush()
}
enum Input {
Command(ClientMessage),
Invalid(String),
Closed,
}
pub fn run_agent<S, R, W>(
mut source: S,
mut interval: Duration,
input: R,
mut output: W,
) -> io::Result<()>
where
S: DataSource,
R: BufRead + Send + 'static,
W: Write,
{
let (tx, rx) = mpsc::channel();
thread::Builder::new()
.name("rigtop-agent-input".to_owned())
.spawn(move || {
for line in input.lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let message = match serde_json::from_str::<ClientMessage>(&line) {
Ok(command) => Input::Command(command),
Err(err) => Input::Invalid(format!("invalid command {line:?}: {err}")),
};
if tx.send(message).is_err() {
return;
}
}
let _ = tx.send(Input::Closed);
})?;
let hello = AgentMessage::Hello {
protocol: PROTOCOL_VERSION,
version: env!("CARGO_PKG_VERSION").to_owned(),
};
if write_message(&mut output, &hello).is_err() {
return Ok(());
}
let mut process_limit: Option<usize> = None;
let mut next_poll = Instant::now() + MIN_POLL_INTERVAL;
loop {
let timeout = next_poll.saturating_duration_since(Instant::now());
let reply = match rx.recv_timeout(timeout) {
Ok(Input::Command(ClientMessage::SetInterval { ms })) => {
interval = Duration::from_millis(ms).max(MIN_POLL_INTERVAL);
next_poll = Instant::now() + interval;
None
}
Ok(Input::Command(ClientMessage::SetProcessLimit { limit })) => {
process_limit = limit;
source.set_process_limit(limit);
None
}
Ok(Input::Command(ClientMessage::Signal { pid, signal })) => {
let error = source
.signal_process(pid, signal)
.err()
.map(|e| e.to_string());
Some(AgentMessage::SignalResult { pid, signal, error })
}
Ok(Input::Invalid(message)) => Some(AgentMessage::Error { message }),
Ok(Input::Closed) | Err(RecvTimeoutError::Disconnected) => return Ok(()),
Err(RecvTimeoutError::Timeout) => {
let message = match source.poll() {
Ok(mut snapshot) => {
if let Some(limit) = process_limit {
snapshot.truncate_processes(limit);
}
AgentMessage::Snapshot {
snapshot: Box::new(snapshot),
}
}
Err(err) => AgentMessage::Error {
message: err.to_string(),
},
};
next_poll = (next_poll + interval).max(Instant::now() + MIN_POLL_INTERVAL);
Some(message)
}
};
if let Some(message) = reply
&& write_message(&mut output, &message).is_err()
{
return Ok(());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::snapshot::ProcessInfo;
use crate::source::SourceError;
use std::io::Cursor;
use std::sync::{Arc, Mutex};
struct FakeSource;
impl DataSource for FakeSource {
fn poll(&mut self) -> Result<Snapshot, SourceError> {
Ok(Snapshot {
processes: (1..=20)
.map(|pid| ProcessInfo {
pid,
cpu_percent: pid as f32,
..Default::default()
})
.collect(),
process_count: 20,
..Default::default()
})
}
fn signal_process(&mut self, pid: u32, _signal: ProcessSignal) -> Result<(), SourceError> {
if pid == 1 {
Err(SourceError::Disconnected("denied".to_owned()))
} else {
Ok(())
}
}
}
#[derive(Clone, Default)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn messages_round_trip_as_tagged_json() {
let command = ClientMessage::Signal {
pid: 42,
signal: ProcessSignal::Kill,
};
let line = serde_json::to_string(&command).unwrap();
assert_eq!(line, r#"{"type":"signal","pid":42,"signal":"Kill"}"#);
assert_eq!(
serde_json::from_str::<ClientMessage>(&line).unwrap(),
command
);
let limit: ClientMessage =
serde_json::from_str(r#"{"type":"set_process_limit","limit":null}"#).unwrap();
assert_eq!(limit, ClientMessage::SetProcessLimit { limit: None });
}
#[test]
fn agent_answers_commands_and_stops_at_end_of_input() {
let input = Cursor::new(
"{\"type\":\"set_process_limit\",\"limit\":3}\n\
{\"type\":\"signal\",\"pid\":1,\"signal\":\"Terminate\"}\n\
not json\n",
);
let output = SharedBuffer::default();
run_agent(FakeSource, Duration::from_secs(1), input, output.clone()).unwrap();
let text = String::from_utf8(output.0.lock().unwrap().clone()).unwrap();
let messages: Vec<AgentMessage> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert!(matches!(
messages[0],
AgentMessage::Hello {
protocol: PROTOCOL_VERSION,
..
}
));
assert!(matches!(
&messages[1],
AgentMessage::SignalResult { pid: 1, error: Some(e), .. } if e.contains("denied")
));
assert!(
matches!(&messages[2], AgentMessage::Error { message } if message.contains("invalid command"))
);
assert_eq!(
messages.len(),
3,
"no snapshot before the first interval: {text}"
);
}
#[test]
fn snapshots_respect_the_process_limit() {
let (reader, mut writer) = std::io::pipe().unwrap();
let output = SharedBuffer::default();
let out = output.clone();
let agent = thread::spawn(move || {
run_agent(
FakeSource,
MIN_POLL_INTERVAL,
io::BufReader::new(reader),
out,
)
});
writeln!(writer, r#"{{"type":"set_process_limit","limit":3}}"#).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let snapshot = loop {
let text = String::from_utf8(output.0.lock().unwrap().clone()).unwrap();
let found = text.lines().find_map(|l| match serde_json::from_str(l) {
Ok(AgentMessage::Snapshot { snapshot }) => Some(snapshot),
_ => None,
});
if let Some(snapshot) = found {
break snapshot;
}
assert!(Instant::now() < deadline, "no snapshot: {text}");
thread::sleep(Duration::from_millis(20));
};
assert_eq!(snapshot.processes.len(), 3);
assert_eq!(snapshot.processes[0].pid, 20, "top CPU first");
assert_eq!(snapshot.process_count, 20);
drop(writer);
agent.join().unwrap().unwrap();
}
}