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                None
147            }
148            Ok(Input::Command(ClientMessage::Signal { pid, signal })) => {
149                let error = source
150                    .signal_process(pid, signal)
151                    .err()
152                    .map(|e| e.to_string());
153                Some(AgentMessage::SignalResult { pid, signal, error })
154            }
155            Ok(Input::Invalid(message)) => Some(AgentMessage::Error { message }),
156            Ok(Input::Closed) | Err(RecvTimeoutError::Disconnected) => return Ok(()),
157            Err(RecvTimeoutError::Timeout) => {
158                let message = match source.poll() {
159                    Ok(mut snapshot) => {
160                        if let Some(limit) = process_limit {
161                            snapshot.truncate_processes(limit);
162                        }
163                        AgentMessage::Snapshot {
164                            snapshot: Box::new(snapshot),
165                        }
166                    }
167                    Err(err) => AgentMessage::Error {
168                        message: err.to_string(),
169                    },
170                };
171                next_poll = (next_poll + interval).max(Instant::now() + MIN_POLL_INTERVAL);
172                Some(message)
173            }
174        };
175        if let Some(message) = reply
176            && write_message(&mut output, &message).is_err()
177        {
178            return Ok(());
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::snapshot::ProcessInfo;
187    use crate::source::SourceError;
188    use std::io::Cursor;
189    use std::sync::{Arc, Mutex};
190
191    struct FakeSource;
192
193    impl DataSource for FakeSource {
194        fn poll(&mut self) -> Result<Snapshot, SourceError> {
195            Ok(Snapshot {
196                processes: (1..=20)
197                    .map(|pid| ProcessInfo {
198                        pid,
199                        cpu_percent: pid as f32,
200                        ..Default::default()
201                    })
202                    .collect(),
203                process_count: 20,
204                ..Default::default()
205            })
206        }
207
208        fn signal_process(&mut self, pid: u32, _signal: ProcessSignal) -> Result<(), SourceError> {
209            if pid == 1 {
210                Err(SourceError::Disconnected("denied".to_owned()))
211            } else {
212                Ok(())
213            }
214        }
215    }
216
217    /// A writer whose contents can be read after the agent has finished.
218    #[derive(Clone, Default)]
219    struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
220
221    impl Write for SharedBuffer {
222        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
223            self.0.lock().unwrap().write(buf)
224        }
225        fn flush(&mut self) -> io::Result<()> {
226            Ok(())
227        }
228    }
229
230    #[test]
231    fn messages_round_trip_as_tagged_json() {
232        let command = ClientMessage::Signal {
233            pid: 42,
234            signal: ProcessSignal::Kill,
235        };
236        let line = serde_json::to_string(&command).unwrap();
237        assert_eq!(line, r#"{"type":"signal","pid":42,"signal":"Kill"}"#);
238        assert_eq!(
239            serde_json::from_str::<ClientMessage>(&line).unwrap(),
240            command
241        );
242        let limit: ClientMessage =
243            serde_json::from_str(r#"{"type":"set_process_limit","limit":null}"#).unwrap();
244        assert_eq!(limit, ClientMessage::SetProcessLimit { limit: None });
245    }
246
247    #[test]
248    fn agent_answers_commands_and_stops_at_end_of_input() {
249        // Commands are processed before the first poll (which waits
250        // MIN_POLL_INTERVAL); end of input stops the agent.
251        let input = Cursor::new(
252            "{\"type\":\"set_process_limit\",\"limit\":3}\n\
253             {\"type\":\"signal\",\"pid\":1,\"signal\":\"Terminate\"}\n\
254             not json\n",
255        );
256        let output = SharedBuffer::default();
257        run_agent(FakeSource, Duration::from_secs(1), input, output.clone()).unwrap();
258
259        let text = String::from_utf8(output.0.lock().unwrap().clone()).unwrap();
260        let messages: Vec<AgentMessage> = text
261            .lines()
262            .map(|l| serde_json::from_str(l).unwrap())
263            .collect();
264        assert!(matches!(
265            messages[0],
266            AgentMessage::Hello {
267                protocol: PROTOCOL_VERSION,
268                ..
269            }
270        ));
271        assert!(matches!(
272            &messages[1],
273            AgentMessage::SignalResult { pid: 1, error: Some(e), .. } if e.contains("denied")
274        ));
275        assert!(
276            matches!(&messages[2], AgentMessage::Error { message } if message.contains("invalid command"))
277        );
278        assert_eq!(
279            messages.len(),
280            3,
281            "no snapshot before the first interval: {text}"
282        );
283    }
284
285    #[test]
286    fn snapshots_respect_the_process_limit() {
287        // Keep the input open (a pipe) until a snapshot has arrived.
288        let (reader, mut writer) = std::io::pipe().unwrap();
289        let output = SharedBuffer::default();
290        let out = output.clone();
291        let agent = thread::spawn(move || {
292            run_agent(
293                FakeSource,
294                MIN_POLL_INTERVAL,
295                io::BufReader::new(reader),
296                out,
297            )
298        });
299        writeln!(writer, r#"{{"type":"set_process_limit","limit":3}}"#).unwrap();
300        let deadline = Instant::now() + Duration::from_secs(5);
301        let snapshot = loop {
302            let text = String::from_utf8(output.0.lock().unwrap().clone()).unwrap();
303            let found = text.lines().find_map(|l| match serde_json::from_str(l) {
304                Ok(AgentMessage::Snapshot { snapshot }) => Some(snapshot),
305                _ => None,
306            });
307            if let Some(snapshot) = found {
308                break snapshot;
309            }
310            assert!(Instant::now() < deadline, "no snapshot: {text}");
311            thread::sleep(Duration::from_millis(20));
312        };
313        assert_eq!(snapshot.processes.len(), 3);
314        assert_eq!(snapshot.processes[0].pid, 20, "top CPU first");
315        assert_eq!(snapshot.process_count, 20);
316        drop(writer);
317        agent.join().unwrap().unwrap();
318    }
319}