1use 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
29pub const PROTOCOL_VERSION: u32 = 1;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum AgentMessage {
38 Hello {
40 protocol: u32,
42 version: String,
44 },
45 Snapshot {
47 snapshot: Box<Snapshot>,
50 },
51 SignalResult {
53 pid: u32,
54 signal: ProcessSignal,
55 error: Option<String>,
57 },
58 Error { message: String },
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(tag = "type", rename_all = "snake_case")]
65pub enum ClientMessage {
66 SetInterval { ms: u64 },
68 SetProcessLimit { limit: Option<usize> },
71 Signal { pid: u32, signal: ProcessSignal },
73}
74
75pub 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
82enum Input {
84 Command(ClientMessage),
85 Invalid(String),
86 Closed,
87}
88
89pub 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 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 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 #[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 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 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}