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