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