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}