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::mpsc::{sync_channel, SyncSender};
25use std::thread;
26
27use crate::livereload::LiveReload;
28use crate::reporter::RebuildReporter;
29
30/// Tick-channel capacity. Bounded so a watcher event burst can't
31/// unboundedly queue inside the rebuild worker. The worker drains all
32/// pending ticks before each build, so a dropped `try_send` on a full
33/// channel coalesces the same way the drain loop does.
34const TICK_CHANNEL_CAPACITY: usize = 8;
35
36/// Tick sender given to the watcher closure. Cheap to clone.
37///
38/// Each tick carries the changed paths from one debounce window, so the worker
39/// can name the file(s) that triggered the rebuild. Send an empty `Vec` to
40/// force a rebuild with no attributed trigger.
41pub type TickSender = SyncSender<Vec<PathBuf>>;
42
43/// Failure returned by a rebuild closure.
44///
45/// Carries two renderings of the same error so the worker can route each to
46/// the surface that needs it:
47///
48/// - `display` — text for the terminal [`RebuildReporter`]. May embed ANSI
49/// color, but each line must keep its **own** self-contained SGR span:
50/// a single span that straddles newlines bleeds when the line is later
51/// re-prefixed (e.g. `[server]` under `xtask dev`).
52/// - `message` — plain, color-free text broadcast to connected browsers over
53/// live-reload (logged to the dev console / shown in overlays). Never embed
54/// ANSI here — browsers render escape codes as literal garbage.
55///
56/// Use [`RebuildError::plain`] (or the `From<String>` / `From<&str>`
57/// conversions) when one rendering serves both surfaces.
58pub struct RebuildError {
59 display: String,
60 message: String,
61}
62
63impl RebuildError {
64 /// Build an error with distinct terminal `display` and browser `message`
65 /// renderings. The caller owns colorization of `display`.
66 #[must_use]
67 pub fn new(display: String, message: String) -> Self {
68 Self { display, message }
69 }
70
71 /// Build an error whose terminal and browser renderings are identical.
72 /// Use when the failure has no structured form to colorize.
73 #[must_use]
74 pub fn plain(text: String) -> Self {
75 Self {
76 display: text.clone(),
77 message: text,
78 }
79 }
80}
81
82impl From<String> for RebuildError {
83 fn from(text: String) -> Self {
84 Self::plain(text)
85 }
86}
87
88impl From<&str> for RebuildError {
89 fn from(text: &str) -> Self {
90 Self::plain(text.to_owned())
91 }
92}
93
94/// Spawn the rebuild worker on a dedicated OS thread and return the
95/// sender used to enqueue rebuild ticks. The watcher closure should
96/// call `tx.try_send(paths)` with the changed paths for every filesystem
97/// event burst — failed sends (channel full) are intentional coalescing.
98///
99/// The closure runs synchronously on the worker thread, so it may use
100/// blocking I/O freely. It returns `Ok(warnings)` on success (a possibly-empty
101/// list of plain, color-free advisory lines to print under the rebuild line) or
102/// `Err(RebuildError)` on failure; the worker prints the error's terminal
103/// rendering and broadcasts its plain message to connected browsers.
104///
105/// The returned [`TickSender`] does not need to be held to keep the
106/// worker alive — the worker stops only when every clone of the
107/// sender is dropped.
108pub fn spawn_rebuild_worker<F>(livereload: LiveReload, mut rebuild: F) -> TickSender
109where
110 F: FnMut() -> Result<Vec<String>, RebuildError> + Send + 'static,
111{
112 let (tx, rx) = sync_channel::<Vec<PathBuf>>(TICK_CHANNEL_CAPACITY);
113 thread::spawn(move || {
114 let mut reporter = RebuildReporter::new();
115 let mut dirty = false;
116 // Paths from ticks that arrived during the previous build belong to the
117 // rebuild we are about to run, so carry them across iterations.
118 let mut carryover: Vec<PathBuf> = Vec::new();
119 loop {
120 let mut triggers = std::mem::take(&mut carryover);
121 if dirty {
122 // A rebuild we just finished was racing with new events.
123 // Skip the blocking recv so we rebuild immediately —
124 // but still drain any ticks that piled up so they
125 // collapse into this iteration.
126 while let Ok(paths) = rx.try_recv() {
127 triggers.extend(paths);
128 }
129 } else {
130 // Block for the first tick. Channel closed (every
131 // external sender dropped) → exit cleanly so the
132 // process can shut down without a zombie thread.
133 match rx.recv() {
134 Ok(paths) => triggers.extend(paths),
135 Err(_) => break,
136 }
137 // Drain extra ticks that piled up while we were
138 // waiting — they all collapse into this rebuild.
139 while let Ok(paths) = rx.try_recv() {
140 triggers.extend(paths);
141 }
142 }
143
144 let start = std::time::Instant::now();
145 let result = rebuild();
146
147 // Drain ticks that arrived during the build (= dirty events).
148 // If any showed up, the next iteration runs without blocking on
149 // `recv` so the user sees their change ASAP; their paths carry over
150 // so they are attributed to that next rebuild, not this one.
151 dirty = false;
152 while let Ok(paths) = rx.try_recv() {
153 dirty = true;
154 carryover.extend(paths);
155 }
156
157 match result {
158 Ok(warnings) => {
159 reporter.success(start.elapsed(), &triggers);
160 reporter.warnings(&warnings);
161 livereload.broadcast_reload();
162 }
163 Err(e) => {
164 reporter.error(&e.display);
165 livereload.broadcast_error(e.message);
166 }
167 }
168 }
169 });
170 tx
171}