Skip to main content

orinium_browser/engine/
background_worker.rs

1use std::sync::mpsc::{self, Receiver, Sender};
2use std::sync::{Arc, Mutex};
3use std::thread;
4
5/// A generic background worker that accepts commands of type `C` and returns
6/// results of type `R` via a command/response channel pair.
7///
8/// One or more worker threads share a single command channel via
9/// `Arc<Mutex<Receiver<C>>>` (competing-consumer pattern). An idle worker
10/// automatically picks up the next available command.
11///
12/// # Type parameters
13///
14/// * `C` – command type sent from the UI thread to the worker(s)
15/// * `R` – result type sent back from the worker(s) to the UI thread
16pub struct BackgroundWorker<C, R> {
17    cmd_tx: Sender<C>,
18    result_rx: Receiver<R>,
19}
20
21impl<C, R> std::fmt::Debug for BackgroundWorker<C, R> {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        f.debug_struct("BackgroundWorker").finish()
24    }
25}
26
27impl<C: Send + 'static, R: Send + 'static> BackgroundWorker<C, R> {
28    /// Spawns `worker_count` threads, each running `process(cmd) -> result` in
29    /// a loop until the command channel disconnects.
30    pub fn new<F>(worker_count: usize, process: F) -> Self
31    where
32        F: Fn(C) -> R + Send + Sync + 'static,
33    {
34        Self::new_with_init(worker_count, || (), move |(), cmd| process(cmd))
35    }
36
37    /// Like [`BackgroundWorker::new`], but each worker thread first runs
38    /// `init()` once and passes its value to every `process(ctx, cmd)` call.
39    ///
40    /// Use this when workers need expensive per-thread state (e.g. an async
41    /// runtime) that must not be rebuilt per command. The context never
42    /// leaves the thread that created it, so `T` may be `!Send`.
43    pub fn new_with_init<T, F, G>(worker_count: usize, init: F, process: G) -> Self
44    where
45        T: 'static,
46        F: Fn() -> T + Send + Sync + 'static,
47        G: Fn(&T, C) -> R + Send + Sync + 'static,
48    {
49        let (cmd_tx, cmd_rx) = mpsc::channel::<C>();
50        let (result_tx, result_rx) = mpsc::channel::<R>();
51        let cmd_rx = Arc::new(Mutex::new(cmd_rx));
52
53        let init = Arc::new(init);
54        let process = Arc::new(process);
55        for _ in 0..worker_count {
56            let cmd_rx = Arc::clone(&cmd_rx);
57            let result_tx = result_tx.clone();
58            let init = Arc::clone(&init);
59            let process = Arc::clone(&process);
60            thread::spawn(move || {
61                let ctx = init();
62                loop {
63                    let cmd = match cmd_rx.lock().unwrap().recv() {
64                        Ok(cmd) => cmd,
65                        Err(_) => {
66                            log::debug!("BackgroundWorker: worker exiting, command channel closed");
67                            break;
68                        }
69                    };
70                    let result = process(&ctx, cmd);
71                    let _ = result_tx.send(result);
72                }
73            });
74        }
75
76        Self { cmd_tx, result_rx }
77    }
78
79    /// Sends a command to the worker pool. The command is delivered to whichever
80    /// worker thread acquires the lock first.
81    ///
82    /// Logs an error when every worker has already exited: in that state the
83    /// command cannot be delivered and is dropped, so callers should treat the
84    /// request as lost.
85    pub fn send(&self, cmd: C) {
86        if self.cmd_tx.send(cmd).is_err() {
87            log::error!("BackgroundWorker: command dropped, all worker threads have exited");
88        }
89    }
90
91    /// Returns a completed result, or `None` if none is ready yet.
92    /// This never blocks the calling thread.
93    pub fn try_receive(&self) -> Option<R> {
94        self.result_rx.try_recv().ok()
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use std::time::Duration;
101
102    use super::*;
103
104    fn wait_for_results<C, R>(worker: &BackgroundWorker<C, R>, count: usize) -> Vec<R>
105    where
106        C: Send + 'static,
107        R: Send + 'static,
108    {
109        let deadline = std::time::Instant::now() + Duration::from_secs(5);
110        let mut results = Vec::new();
111        while results.len() < count {
112            if let Some(result) = worker.try_receive() {
113                results.push(result);
114                continue;
115            }
116            assert!(
117                std::time::Instant::now() < deadline,
118                "worker results did not arrive before the timeout"
119            );
120            thread::sleep(Duration::from_millis(1));
121        }
122        results
123    }
124
125    #[test]
126    fn every_command_is_processed_exactly_once() {
127        const COMMANDS: usize = 32;
128        let worker = BackgroundWorker::new(4, |n: usize| n * 2);
129        for n in 0..COMMANDS {
130            worker.send(n);
131        }
132
133        let mut results = wait_for_results(&worker, COMMANDS);
134        results.sort_unstable();
135        let doubled: Vec<_> = (0..COMMANDS).map(|n| n * 2).collect();
136        assert_eq!(results, doubled);
137    }
138
139    #[test]
140    fn init_runs_once_per_worker_and_context_is_shared_by_that_thread() {
141        use std::sync::atomic::{AtomicUsize, Ordering};
142
143        static INIT_CALLS: AtomicUsize = AtomicUsize::new(0);
144        const WORKERS: usize = 3;
145        const COMMANDS: usize = 12;
146
147        let worker = BackgroundWorker::<(), usize>::new_with_init(
148            WORKERS,
149            || INIT_CALLS.fetch_add(1, Ordering::SeqCst),
150            |ctx, (): ()| *ctx,
151        );
152        for _ in 0..COMMANDS {
153            worker.send(());
154        }
155
156        let results = wait_for_results(&worker, COMMANDS);
157        // Every command observes one of the per-thread contexts.
158        assert!(results.iter().all(|ctx| *ctx < WORKERS));
159        assert_eq!(
160            INIT_CALLS.load(Ordering::SeqCst),
161            WORKERS,
162            "each worker must initialize its context exactly once"
163        );
164    }
165
166    #[test]
167    fn non_send_context_is_accepted() {
168        #[allow(dead_code)]
169        struct NotSend(std::rc::Rc<()>);
170        let worker = BackgroundWorker::<usize, usize>::new_with_init(
171            2,
172            || NotSend(std::rc::Rc::new(())),
173            |_ctx, n: usize| n + 1,
174        );
175        for n in 0..4 {
176            worker.send(n);
177        }
178        let mut results = wait_for_results(&worker, 4);
179        results.sort_unstable();
180        assert_eq!(results, vec![1, 2, 3, 4]);
181    }
182}