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