Skip to main content

rigtop_core/
agent.rs

1//! The agent protocol for remote monitoring, and the agent loop itself.
2//!
3//! A client runs `ssh host rigtop --agent`; the agent writes snapshots to
4//! stdout and reads commands from stdin. Both directions use JSON Lines: one
5//! JSON object per line, tagged by `"type"`:
6//!
7//! ```text
8//! agent  -> client  {"type":"hello","protocol":1,"version":"0.1.0"}
9//! agent  -> client  {"type":"snapshot","snapshot":{...}}
10//! client -> agent   {"type":"set_interval","ms":2000}
11//! client -> agent   {"type":"set_process_limit","limit":10}
12//! client -> agent   {"type":"signal","pid":1234,"signal":"Terminate"}
13//! agent  -> client  {"type":"signal_result","pid":1234,"signal":"Terminate","error":null}
14//! ```
15//!
16//! The agent exits when stdin reaches end of file, so it never outlives the
17//! SSH connection that started it.
18
19use std::io::{self, BufRead, Write};
20use std::sync::mpsc::{self, RecvTimeoutError};
21use std::thread;
22use std::time::{Duration, Instant};
23
24use serde::{Deserialize, Serialize};
25
26use crate::snapshot::Snapshot;
27use crate::source::{DataSource, MIN_POLL_INTERVAL, ProcessSignal};
28
29/// Version of the wire format. Bumped on incompatible changes; adding
30/// fields to snapshots does not need a bump (all snapshot types default
31/// missing fields).
32pub const PROTOCOL_VERSION: u32 = 1;
33
34/// Messages from the agent to the client.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum AgentMessage {
38    /// First message after start-up.
39    Hello {
40        /// [`PROTOCOL_VERSION`] of the agent.
41        protocol: u32,
42        /// rigtop version of the agent.
43        version: String,
44    },
45    /// A new measurement.
46    Snapshot {
47        /// The measurement (processes possibly limited, see
48        /// [`ClientMessage::SetProcessLimit`]).
49        snapshot: Box<Snapshot>,
50    },
51    /// Outcome of a [`ClientMessage::Signal`].
52    SignalResult {
53        pid: u32,
54        signal: ProcessSignal,
55        /// `None` on success.
56        error: Option<String>,
57    },
58    /// A problem the agent wants to report without exiting.
59    Error { message: String },
60}
61
62/// Messages from the client to the agent.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case")]
65pub enum ClientMessage {
66    /// Change the sampling interval.
67    SetInterval { ms: u64 },
68    /// Send only the top `limit` processes by CPU (`None`: all). Used for
69    /// hosts shown in an overview rather than in detail.
70    SetProcessLimit { limit: Option<usize> },
71    /// Send a signal to a process on the agent's host.
72    Signal { pid: u32, signal: ProcessSignal },
73}
74
75/// Writes one message as a JSON line and flushes it.
76pub fn write_message<W: Write, M: Serialize>(writer: &mut W, message: &M) -> io::Result<()> {
77    serde_json::to_writer(&mut *writer, message)?;
78    writer.write_all(b"\n")?;
79    writer.flush()
80}
81
82/// What the stdin reader thread hands to the agent loop.
83enum Input {
84    Command(ClientMessage),
85    Invalid(String),
86    Closed,
87}
88
89/// Runs the agent until `input` is closed or `output` fails.
90///
91/// `source` is polled every `interval` (changeable by the client); each
92/// snapshot is written to `output`. Commands are read from `input` on a
93/// separate thread.
94pub fn run_agent<S, R, W>(
95    mut source: S,
96    mut interval: Duration,
97    input: R,
98    mut output: W,
99) -> io::Result<()>
100where
101    S: DataSource,
102    R: BufRead + Send + 'static,
103    W: Write,
104{
105    let (tx, rx) = mpsc::channel();
106    thread::Builder::new()
107        .name("rigtop-agent-input".to_owned())
108        .spawn(move || {
109            for line in input.lines() {
110                let Ok(line) = line else { break };
111                if line.trim().is_empty() {
112                    continue;
113                }
114                let message = match serde_json::from_str::<ClientMessage>(&line) {
115                    Ok(command) => Input::Command(command),
116                    Err(err) => Input::Invalid(format!("invalid command {line:?}: {err}")),
117                };
118                if tx.send(message).is_err() {
119                    return;
120                }
121            }
122            let _ = tx.send(Input::Closed);
123        })?;
124
125    let hello = AgentMessage::Hello {
126        protocol: PROTOCOL_VERSION,
127        version: env!("CARGO_PKG_VERSION").to_owned(),
128    };
129    // A failed write means the client went away: stop quietly.
130    if write_message(&mut output, &hello).is_err() {
131        return Ok(());
132    }
133
134    let mut process_limit: Option<usize> = None;
135    let mut next_poll = Instant::now() + MIN_POLL_INTERVAL;
136    loop {
137        let timeout = next_poll.saturating_duration_since(Instant::now());
138        let reply = match rx.recv_timeout(timeout) {
139            Ok(Input::Command(ClientMessage::SetInterval { ms })) => {
140                interval = Duration::from_millis(ms).max(MIN_POLL_INTERVAL);
141                next_poll = Instant::now() + interval;
142                None
143            }
144            Ok(Input::Command(ClientMessage::SetProcessLimit { limit })) => {
145                process_limit = limit;
146                // The source can skip work only the full list needs.
147                source.set_process_limit(limit);
148                None
149            }
150            Ok(Input::Command(ClientMessage::Signal { pid, signal })) => {
151                let error = source
152                    .signal_process(pid, signal)
153                    .err()
154                    .map(|e| e.to_string());
155                Some(AgentMessage::SignalResult { pid, signal, error })
156            }
157            Ok(Input::Invalid(message)) => Some(AgentMessage::Error { message }),
158            Ok(Input::Closed) | Err(RecvTimeoutError::Disconnected) => return Ok(()),
159            Err(RecvTimeoutError::Timeout) => {
160                let message = match source.poll() {
161                    Ok(mut snapshot) => {
162                        if let Some(limit) = process_limit {
163                            snapshot.truncate_processes(limit);
164                        }
165                        AgentMessage::Snapshot {
166                            snapshot: Box::new(snapshot),
167                        }
168                    }
169                    Err(err) => AgentMessage::Error {
170                        message: err.to_string(),
171                    },
172                };
173                next_poll = (next_poll + interval).max(Instant::now() + MIN_POLL_INTERVAL);
174                Some(message)
175            }
176        };
177        if let Some(message) = reply
178            && write_message(&mut output, &message).is_err()
179        {
180            return Ok(());
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::snapshot::ProcessInfo;
189    use crate::source::SourceError;
190    use std::io::Cursor;
191    use std::sync::{Arc, Mutex};
192
193    struct FakeSource;
194
195    impl DataSource for FakeSource {
196        fn poll(&mut self) -> Result<Snapshot, SourceError> {
197            Ok(Snapshot {
198                processes: (1..=20)
199                    .map(|pid| ProcessInfo {
200                        pid,
201                        cpu_percent: pid as f32,
202                        ..Default::default()
203                    })
204                    .collect(),
205                process_count: 20,
206                ..Default::default()
207            })
208        }
209
210        fn signal_process(&mut self, pid: u32, _signal: ProcessSignal) -> Result<(), SourceError> {
211            if pid == 1 {
212                Err(SourceError::Disconnected("denied".to_owned()))
213            } else {
214                Ok(())
215            }
216        }
217    }
218
219    /// A writer whose contents can be read after the agent has finished.
220    #[derive(Clone, Default)]
221    struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
222
223    impl Write for SharedBuffer {
224        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
225            self.0.lock().unwrap().write(buf)
226        }
227        fn flush(&mut self) -> io::Result<()> {
228            Ok(())
229        }
230    }
231
232    #[test]
233    fn messages_round_trip_as_tagged_json() {
234        let command = ClientMessage::Signal {
235            pid: 42,
236            signal: ProcessSignal::Kill,
237        };
238        let line = serde_json::to_string(&command).unwrap();
239        assert_eq!(line, r#"{"type":"signal","pid":42,"signal":"Kill"}"#);
240        assert_eq!(
241            serde_json::from_str::<ClientMessage>(&line).unwrap(),
242            command
243        );
244        let limit: ClientMessage =
245            serde_json::from_str(r#"{"type":"set_process_limit","limit":null}"#).unwrap();
246        assert_eq!(limit, ClientMessage::SetProcessLimit { limit: None });
247    }
248
249    #[test]
250    fn agent_answers_commands_and_stops_at_end_of_input() {
251        // Commands are processed before the first poll (which waits
252        // MIN_POLL_INTERVAL); end of input stops the agent.
253        let input = Cursor::new(
254            "{\"type\":\"set_process_limit\",\"limit\":3}\n\
255             {\"type\":\"signal\",\"pid\":1,\"signal\":\"Terminate\"}\n\
256             not json\n",
257        );
258        let output = SharedBuffer::default();
259        run_agent(FakeSource, Duration::from_secs(1), input, output.clone()).unwrap();
260
261        let text = String::from_utf8(output.0.lock().unwrap().clone()).unwrap();
262        let messages: Vec<AgentMessage> = text
263            .lines()
264            .map(|l| serde_json::from_str(l).unwrap())
265            .collect();
266        assert!(matches!(
267            messages[0],
268            AgentMessage::Hello {
269                protocol: PROTOCOL_VERSION,
270                ..
271            }
272        ));
273        assert!(matches!(
274            &messages[1],
275            AgentMessage::SignalResult { pid: 1, error: Some(e), .. } if e.contains("denied")
276        ));
277        assert!(
278            matches!(&messages[2], AgentMessage::Error { message } if message.contains("invalid command"))
279        );
280        assert_eq!(
281            messages.len(),
282            3,
283            "no snapshot before the first interval: {text}"
284        );
285    }
286
287    #[test]
288    fn snapshots_respect_the_process_limit() {
289        // Keep the input open (a pipe) until a snapshot has arrived.
290        let (reader, mut writer) = std::io::pipe().unwrap();
291        let output = SharedBuffer::default();
292        let out = output.clone();
293        let agent = thread::spawn(move || {
294            run_agent(
295                FakeSource,
296                MIN_POLL_INTERVAL,
297                io::BufReader::new(reader),
298                out,
299            )
300        });
301        writeln!(writer, r#"{{"type":"set_process_limit","limit":3}}"#).unwrap();
302        let deadline = Instant::now() + Duration::from_secs(5);
303        let snapshot = loop {
304            let text = String::from_utf8(output.0.lock().unwrap().clone()).unwrap();
305            let found = text.lines().find_map(|l| match serde_json::from_str(l) {
306                Ok(AgentMessage::Snapshot { snapshot }) => Some(snapshot),
307                _ => None,
308            });
309            if let Some(snapshot) = found {
310                break snapshot;
311            }
312            assert!(Instant::now() < deadline, "no snapshot: {text}");
313            thread::sleep(Duration::from_millis(20));
314        };
315        assert_eq!(snapshot.processes.len(), 3);
316        assert_eq!(snapshot.processes[0].pid, 20, "top CPU first");
317        assert_eq!(snapshot.process_count, 20);
318        drop(writer);
319        agent.join().unwrap().unwrap();
320    }
321}