Skip to main content

rigtop_core/
worker.rs

1//! Background thread that owns the data source.
2//!
3//! Polling (and, for remote sources, waiting on the network) happens off the
4//! UI thread so that input stays responsive. The UI sends [`Command`]s and
5//! receives [`Event`]s over channels.
6
7use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, SyncSender, TrySendError};
8use std::thread::{self, JoinHandle};
9use std::time::{Duration, Instant};
10
11use crate::{DataSource, MIN_POLL_INTERVAL, ProcessSignal, Snapshot};
12
13/// Events kept for the UI before new ones are dropped (a few seconds of
14/// snapshots at the default interval).
15const EVENT_QUEUE: usize = 8;
16
17/// Requests from the UI to the worker.
18#[derive(Debug)]
19pub enum Command {
20    /// Send a signal to a process on the monitored host.
21    Signal { pid: u32, signal: ProcessSignal },
22    /// Change the polling interval.
23    SetInterval(Duration),
24    /// Limit snapshots to the top processes (`None`: all).
25    SetProcessLimit(Option<usize>),
26    /// Processes to report the exit of (see [`DataSource::watch_processes`]).
27    WatchProcesses(Vec<u32>),
28}
29
30/// Messages from the worker to the UI.
31#[derive(Debug)]
32pub enum Event {
33    /// A new measurement.
34    Snapshot(Box<Snapshot>),
35    /// Outcome of a [`Command::Signal`].
36    SignalResult {
37        pid: u32,
38        signal: ProcessSignal,
39        result: Result<(), String>,
40    },
41    /// Polling failed; the worker keeps trying.
42    Error(String),
43}
44
45/// Handle to the worker thread. Dropping it stops the thread.
46pub struct Worker {
47    commands: Option<Sender<Command>>,
48    events: Receiver<Event>,
49    handle: Option<JoinHandle<()>>,
50}
51
52impl Worker {
53    /// Starts the worker. `make_source` runs on the worker thread, so slow
54    /// initialization (NVML, warm-up sample, SSH connection) does not delay
55    /// the first frame.
56    pub fn spawn<S, F>(make_source: F, interval: Duration) -> Self
57    where
58        S: DataSource + 'static,
59        F: FnOnce() -> S + Send + 'static,
60    {
61        Self::spawn_with_notify(make_source, interval, || {})
62    }
63
64    /// Like [`Worker::spawn`], and calls `notify` after every event so that
65    /// an event-driven UI (e.g. egui's `request_repaint`) can wake up instead
66    /// of polling.
67    pub fn spawn_with_notify<S, F, N>(make_source: F, interval: Duration, notify: N) -> Self
68    where
69        S: DataSource + 'static,
70        F: FnOnce() -> S + Send + 'static,
71        N: Fn() + Send + 'static,
72    {
73        let (command_tx, command_rx) = mpsc::channel();
74        // Bounded: a UI that stops draining (e.g. a hidden window) must not
75        // make the queue grow without limit; events beyond it are dropped.
76        let (event_tx, event_rx) = mpsc::sync_channel(EVENT_QUEUE);
77        let handle = thread::Builder::new()
78            .name("rigtop-collector".to_owned())
79            .spawn(move || {
80                let events = Notifying {
81                    sender: event_tx,
82                    notify,
83                };
84                run(make_source(), interval, &command_rx, &events)
85            })
86            .expect("failed to spawn the collector thread");
87        Self {
88            commands: Some(command_tx),
89            events: event_rx,
90            handle: Some(handle),
91        }
92    }
93
94    /// Sends a command; silently ignored if the worker has stopped.
95    pub fn send(&self, command: Command) {
96        if let Some(tx) = &self.commands {
97            let _ = tx.send(command);
98        }
99    }
100
101    /// Returns the next pending event without blocking.
102    pub fn try_recv(&self) -> Option<Event> {
103        self.events.try_recv().ok()
104    }
105}
106
107impl Drop for Worker {
108    fn drop(&mut self) {
109        // Closing the command channel makes the worker loop exit.
110        self.commands.take();
111        if let Some(handle) = self.handle.take() {
112            let _ = handle.join();
113        }
114    }
115}
116
117/// Worker loop: poll on schedule, execute commands in between.
118/// Event sender that calls a notification hook after each send.
119struct Notifying<N: Fn()> {
120    sender: SyncSender<Event>,
121    notify: N,
122}
123
124impl<N: Fn()> Notifying<N> {
125    /// Queues an event without blocking, so that polling (and draining a
126    /// remote connection) continues even if the UI stops reading. When the
127    /// queue is full the event is dropped. Fails only when the UI is gone.
128    fn send(&self, event: Event) -> Result<(), TrySendError<Event>> {
129        match self.sender.try_send(event) {
130            Ok(()) | Err(TrySendError::Full(_)) => {
131                // Wake the UI either way: a full queue needs draining.
132                (self.notify)();
133                Ok(())
134            }
135            Err(err @ TrySendError::Disconnected(_)) => Err(err),
136        }
137    }
138}
139
140fn run<S: DataSource, N: Fn()>(
141    mut source: S,
142    mut interval: Duration,
143    commands: &Receiver<Command>,
144    events: &Notifying<N>,
145) {
146    // The source has just taken its warm-up sample; the first real sample
147    // only needs the minimum interval, so the UI fills quickly.
148    source.set_interval(interval);
149    let mut last_poll = Instant::now();
150    let mut next_poll = last_poll + MIN_POLL_INTERVAL;
151    loop {
152        let timeout = next_poll.saturating_duration_since(Instant::now());
153        match commands.recv_timeout(timeout) {
154            Ok(Command::Signal { pid, signal }) => {
155                let result = source
156                    .signal_process(pid, signal)
157                    .map_err(|e| e.to_string());
158                if events
159                    .send(Event::SignalResult {
160                        pid,
161                        signal,
162                        result,
163                    })
164                    .is_err()
165                {
166                    return;
167                }
168            }
169            Ok(Command::SetInterval(new_interval)) => {
170                interval = new_interval.max(MIN_POLL_INTERVAL);
171                source.set_interval(interval);
172                next_poll = last_poll + interval;
173            }
174            Ok(Command::SetProcessLimit(limit)) => source.set_process_limit(limit),
175            Ok(Command::WatchProcesses(pids)) => source.watch_processes(pids),
176            Err(RecvTimeoutError::Timeout) => {
177                let event = match source.poll() {
178                    Ok(snapshot) => Event::Snapshot(Box::new(snapshot)),
179                    Err(err) => Event::Error(err.to_string()),
180                };
181                last_poll = Instant::now();
182                // Keep a steady cadence, but never try to catch up on
183                // missed ticks (e.g. after the machine was suspended).
184                next_poll = (next_poll + interval).max(last_poll + MIN_POLL_INTERVAL);
185                if events.send(event).is_err() {
186                    return;
187                }
188            }
189            Err(RecvTimeoutError::Disconnected) => return,
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::SourceError;
198
199    /// A source that counts polls and records signals.
200    struct FakeSource {
201        polls: u64,
202        signals: Sender<(u32, ProcessSignal)>,
203    }
204
205    impl DataSource for FakeSource {
206        fn poll(&mut self) -> Result<Snapshot, SourceError> {
207            self.polls += 1;
208            Ok(Snapshot {
209                timestamp_ms: self.polls,
210                ..Default::default()
211            })
212        }
213
214        fn signal_process(&mut self, pid: u32, signal: ProcessSignal) -> Result<(), SourceError> {
215            self.signals.send((pid, signal)).unwrap();
216            if pid == 1 {
217                Err(SourceError::Disconnected("denied".to_owned()))
218            } else {
219                Ok(())
220            }
221        }
222    }
223
224    fn recv(worker: &Worker) -> Event {
225        let deadline = Instant::now() + Duration::from_secs(5);
226        loop {
227            if let Some(event) = worker.try_recv() {
228                return event;
229            }
230            assert!(Instant::now() < deadline, "no event from worker");
231            thread::sleep(Duration::from_millis(5));
232        }
233    }
234
235    #[test]
236    fn notify_runs_after_each_event() {
237        use std::sync::Arc;
238        use std::sync::atomic::{AtomicUsize, Ordering};
239
240        let (signal_tx, _signal_rx) = mpsc::channel();
241        let count = Arc::new(AtomicUsize::new(0));
242        let counter = Arc::clone(&count);
243        let worker = Worker::spawn_with_notify(
244            move || FakeSource {
245                polls: 0,
246                signals: signal_tx,
247            },
248            MIN_POLL_INTERVAL,
249            move || {
250                counter.fetch_add(1, Ordering::SeqCst);
251            },
252        );
253        recv(&worker);
254        // The event is sent before the notification; wait for the latter.
255        let deadline = Instant::now() + Duration::from_secs(5);
256        while count.load(Ordering::SeqCst) == 0 {
257            assert!(Instant::now() < deadline, "notify was not called");
258            thread::sleep(Duration::from_millis(5));
259        }
260    }
261
262    #[test]
263    fn undrained_events_stay_bounded() {
264        let (signal_tx, _signal_rx) = mpsc::channel();
265        let worker = Worker::spawn(
266            move || FakeSource {
267                polls: 0,
268                signals: signal_tx,
269            },
270            MIN_POLL_INTERVAL,
271        );
272        // About 15 polls happen while nobody reads.
273        thread::sleep(MIN_POLL_INTERVAL * 15);
274        let mut queued = 0;
275        while worker.try_recv().is_some() {
276            queued += 1;
277        }
278        assert!(
279            (1..=EVENT_QUEUE).contains(&queued),
280            "{queued} events queued"
281        );
282        // The worker kept polling: the next snapshot is a recent one.
283        let next = recv(&worker);
284        assert!(matches!(next, Event::Snapshot(s) if s.timestamp_ms > EVENT_QUEUE as u64));
285    }
286
287    #[test]
288    fn delivers_snapshots_and_signal_results() {
289        let (signal_tx, signal_rx) = mpsc::channel();
290        let worker = Worker::spawn(
291            move || FakeSource {
292                polls: 0,
293                signals: signal_tx,
294            },
295            MIN_POLL_INTERVAL,
296        );
297
298        match recv(&worker) {
299            Event::Snapshot(s) => assert_eq!(s.timestamp_ms, 1),
300            other => panic!("unexpected {other:?}"),
301        }
302
303        worker.send(Command::Signal {
304            pid: 1,
305            signal: ProcessSignal::Kill,
306        });
307        // Skip snapshots until the signal result arrives.
308        let result = loop {
309            if let Event::SignalResult { pid, result, .. } = recv(&worker) {
310                assert_eq!(pid, 1);
311                break result;
312            }
313        };
314        assert!(result.unwrap_err().contains("denied"));
315        assert_eq!(signal_rx.recv().unwrap(), (1, ProcessSignal::Kill));
316        // Dropping the worker joins the thread without hanging.
317        drop(worker);
318    }
319}