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