Skip to main content

webui_dev_server/
worker.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Generic rebuild-worker driver for dev servers.
5//!
6//! Spawns a background OS thread that owns the rebuild loop:
7//!  1. Block waiting for a tick.
8//!  2. Drain any extra ticks queued before the thread woke up — they
9//!     coalesce into a single rebuild.
10//!  3. Run the user-supplied rebuild closure.
11//!  4. Drain any ticks that arrived during the rebuild — if any did,
12//!     re-tick ourselves so the next iteration runs without waiting.
13//!  5. Report success / error via [`RebuildReporter`] and broadcast
14//!     the corresponding live-reload event.
15//!
16//! The closure is the only thing that varies between consumers
17//! (webui-cli builds and renders an app, webui-press rebuilds a docs
18//! site). It returns `Result<Vec<String>, RebuildError>`: the `Ok` value is a
19//! list of plain advisory lines to print under the rebuild line, and the `Err`
20//! lets the worker print a terminal rendering and broadcast a plain one to
21//! browsers.
22
23use std::path::PathBuf;
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::sync::mpsc::{sync_channel, SyncSender};
26use std::sync::Arc;
27use std::thread;
28
29use anyhow::{anyhow, Result};
30
31use crate::livereload::LiveReload;
32use crate::reporter::RebuildReporter;
33
34/// Tick-channel capacity. Bounded so a watcher event burst can't
35/// unboundedly queue inside the rebuild worker. The worker drains all
36/// pending ticks before each build, so a dropped `try_send` on a full
37/// channel coalesces the same way the drain loop does.
38const TICK_CHANNEL_CAPACITY: usize = 8;
39
40/// Tick sender given to the watcher closure. Cheap to clone.
41///
42/// Each tick carries the changed paths from one debounce window, so the worker
43/// can name the file(s) that triggered the rebuild. Send an empty `Vec` to
44/// force a rebuild with no attributed trigger.
45pub type TickSender = SyncSender<Vec<PathBuf>>;
46
47/// Owns a rebuild thread. Keep this handle alive for the server's lifetime.
48///
49/// Shutdown discards pending ticks and waits for the active rebuild, including
50/// any subprocesses it waits on. Dropping the handle also shuts down the worker.
51#[must_use]
52pub struct RebuildWorker {
53    sender: TickSender,
54    stopping: Arc<AtomicBool>,
55    thread: Option<thread::JoinHandle<()>>,
56}
57
58impl RebuildWorker {
59    /// Clone the sender used by a watcher to enqueue changed paths.
60    #[must_use]
61    pub fn sender(&self) -> TickSender {
62        self.sender.clone()
63    }
64
65    /// Stop accepting rebuild work and wait for the active rebuild to finish.
66    ///
67    /// Existing sender clones do not prevent shutdown.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the rebuild thread panicked.
72    pub fn shutdown(mut self) -> Result<()> {
73        self.stop_and_join()
74    }
75
76    fn stop_and_join(&mut self) -> Result<()> {
77        self.stopping.store(true, Ordering::Release);
78        // Wake an idle worker. A full queue already provides a wakeup.
79        let _ = self.sender.try_send(Vec::new());
80        if let Some(thread) = self.thread.take() {
81            thread.join().map_err(|_| {
82                anyhow!("Rebuild worker panicked; check the preceding build output")
83            })?;
84        }
85        Ok(())
86    }
87}
88
89impl Drop for RebuildWorker {
90    fn drop(&mut self) {
91        if let Err(error) = self.stop_and_join() {
92            eprintln!("{error}");
93        }
94    }
95}
96
97/// Failure returned by a rebuild closure.
98///
99/// Carries two renderings of the same error so the worker can route each to
100/// the surface that needs it:
101///
102/// - `display` — text for the terminal [`RebuildReporter`]. May embed ANSI
103///   color, but each line must keep its **own** self-contained SGR span:
104///   a single span that straddles newlines bleeds when the line is later
105///   re-prefixed (e.g. `[server]` under `xtask dev`).
106/// - `message` — plain, color-free text broadcast to connected browsers over
107///   live-reload (logged to the dev console / shown in overlays). Never embed
108///   ANSI here — browsers render escape codes as literal garbage.
109///
110/// Use [`RebuildError::plain`] (or the `From<String>` / `From<&str>`
111/// conversions) when one rendering serves both surfaces.
112pub struct RebuildError {
113    display: String,
114    message: String,
115}
116
117impl RebuildError {
118    /// Build an error with distinct terminal `display` and browser `message`
119    /// renderings. The caller owns colorization of `display`.
120    #[must_use]
121    pub fn new(display: String, message: String) -> Self {
122        Self { display, message }
123    }
124
125    /// Build an error whose terminal and browser renderings are identical.
126    /// Use when the failure has no structured form to colorize.
127    #[must_use]
128    pub fn plain(text: String) -> Self {
129        Self {
130            display: text.clone(),
131            message: text,
132        }
133    }
134}
135
136impl From<String> for RebuildError {
137    fn from(text: String) -> Self {
138        Self::plain(text)
139    }
140}
141
142impl From<&str> for RebuildError {
143    fn from(text: &str) -> Self {
144        Self::plain(text.to_owned())
145    }
146}
147
148/// Spawn the rebuild worker on a dedicated OS thread and return the
149/// owned handle used to shut it down. The watcher closure should
150/// call `tx.try_send(paths)` with the changed paths for every filesystem
151/// event burst — failed sends (channel full) are intentional coalescing.
152/// Obtain `tx` via [`RebuildWorker::sender`].
153///
154/// The closure runs synchronously on the worker thread, so it may use
155/// blocking I/O freely. It returns `Ok(warnings)` on success (a possibly-empty
156/// list of plain, color-free advisory lines to print under the rebuild line) or
157/// `Err(RebuildError)` on failure; the worker prints the error's terminal
158/// rendering and broadcasts its plain message to connected browsers.
159///
160/// Keep the returned [`RebuildWorker`] alive until the server stops, then drop
161/// the watcher and call [`RebuildWorker::shutdown`] to wait for active writes.
162pub fn spawn_rebuild_worker<F>(livereload: LiveReload, mut rebuild: F) -> RebuildWorker
163where
164    F: FnMut() -> Result<Vec<String>, RebuildError> + Send + 'static,
165{
166    let (tx, rx) = sync_channel::<Vec<PathBuf>>(TICK_CHANNEL_CAPACITY);
167    let stopping = Arc::new(AtomicBool::new(false));
168    let worker_stopping = Arc::clone(&stopping);
169    let handle = thread::spawn(move || {
170        let mut reporter = RebuildReporter::new();
171        let mut dirty = false;
172        // Paths from ticks that arrived during the previous build belong to the
173        // rebuild we are about to run, so carry them across iterations.
174        let mut carryover: Vec<PathBuf> = Vec::new();
175        while !worker_stopping.load(Ordering::Acquire) {
176            let mut triggers = std::mem::take(&mut carryover);
177            if dirty {
178                // A rebuild we just finished was racing with new events.
179                // Skip the blocking recv so we rebuild immediately —
180                // but still drain any ticks that piled up so they
181                // collapse into this iteration.
182                while let Ok(paths) = rx.try_recv() {
183                    triggers.extend(paths);
184                }
185            } else {
186                // Block until a watcher tick or the shutdown wakeup.
187                match rx.recv() {
188                    Ok(paths) => triggers.extend(paths),
189                    Err(_) => break,
190                }
191                // Drain extra ticks that piled up while we were
192                // waiting — they all collapse into this rebuild.
193                while let Ok(paths) = rx.try_recv() {
194                    triggers.extend(paths);
195                }
196            }
197
198            if worker_stopping.load(Ordering::Acquire) {
199                break;
200            }
201
202            let start = std::time::Instant::now();
203            let result = rebuild();
204
205            // Drain ticks that arrived during the build (= dirty events).
206            // If any showed up, the next iteration runs without blocking on
207            // `recv` so the user sees their change ASAP; their paths carry over
208            // so they are attributed to that next rebuild, not this one.
209            dirty = false;
210            while let Ok(paths) = rx.try_recv() {
211                dirty = true;
212                carryover.extend(paths);
213            }
214
215            match result {
216                Ok(warnings) => {
217                    reporter.success(start.elapsed(), &triggers);
218                    reporter.warnings(&warnings);
219                    livereload.broadcast_reload();
220                }
221                Err(e) => {
222                    reporter.error(&e.display);
223                    livereload.broadcast_error(e.message);
224                }
225            }
226        }
227    });
228    RebuildWorker {
229        sender: tx,
230        stopping,
231        thread: Some(handle),
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::sync::atomic::AtomicUsize;
239    use std::sync::mpsc::{channel, TryRecvError};
240    use std::time::{Duration, Instant};
241
242    #[test]
243    fn shutdown_drains_active_work_but_discards_queued_ticks_with_live_senders() -> Result<()> {
244        let (started_tx, started_rx) = channel();
245        let (release_tx, release_rx) = channel();
246        let completed = Arc::new(AtomicUsize::new(0));
247        let worker_completed = Arc::clone(&completed);
248        let worker = spawn_rebuild_worker(LiveReload::new("/reload"), move || {
249            started_tx.send(()).map_err(|e| e.to_string())?;
250            release_rx.recv().map_err(|e| e.to_string())?;
251            worker_completed.fetch_add(1, Ordering::Release);
252            Ok(Vec::new())
253        });
254        let sender = worker.sender();
255        sender.try_send(Vec::new())?;
256        started_rx.recv_timeout(Duration::from_secs(5))?;
257        for _ in 0..TICK_CHANNEL_CAPACITY {
258            sender.try_send(Vec::new())?;
259        }
260
261        let stopping = Arc::clone(&worker.stopping);
262        let (joined_tx, joined_rx) = channel();
263        let shutdown = thread::spawn(move || {
264            let result = worker.shutdown();
265            let _ = joined_tx.send(());
266            result
267        });
268        let deadline = Instant::now() + Duration::from_secs(5);
269        while !stopping.load(Ordering::Acquire) {
270            assert!(Instant::now() < deadline, "shutdown did not start");
271            thread::yield_now();
272        }
273        assert_eq!(joined_rx.try_recv(), Err(TryRecvError::Empty));
274        assert_eq!(completed.load(Ordering::Acquire), 0);
275        release_tx.send(())?;
276        joined_rx.recv_timeout(Duration::from_secs(5))?;
277        shutdown
278            .join()
279            .map_err(|_| anyhow!("shutdown panicked"))??;
280        assert_eq!(completed.load(Ordering::Acquire), 1);
281        assert!(sender.try_send(Vec::new()).is_err());
282        Ok(())
283    }
284}