mini_build/watch.rs
1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::thread::JoinHandle;
6use std::time::{Duration, SystemTime};
7
8use crate::change::ChangeType;
9use crate::error::BuildError;
10use crate::source::SourcePipeline;
11
12/// How often the watcher re-walks the source tree looking for modified files.
13///
14/// Polling rather than an OS notification API keeps the crate dependency-free; 500 ms is
15/// the interval `mini-static`'s live-reload used for the same job, fast enough to feel
16/// immediate in a browser and slow enough that a large tree costs little.
17const POLL_INTERVAL: Duration = Duration::from_millis(500);
18
19/// A running watch. Dropping it stops the watcher and waits for its thread to finish.
20///
21/// Held rather than detached on purpose: a background thread that outlives the handle
22/// its caller was given is a leak that only shows up as a build mysteriously running
23/// after the caller thought it had stopped.
24pub struct WatchHandle {
25 stop: Arc<AtomicBool>,
26 thread: Option<JoinHandle<()>>,
27}
28
29impl WatchHandle {
30 /// Stop watching and wait for the watcher thread to finish.
31 ///
32 /// Equivalent to dropping the handle; offered explicitly so a caller can stop at a
33 /// chosen point rather than at end of scope.
34 pub fn stop(self) {
35 // The work happens in `Drop`, which runs as this returns.
36 }
37}
38
39impl Drop for WatchHandle {
40 fn drop(&mut self) {
41 self.stop.store(true, Ordering::Relaxed);
42 if let Some(thread) = self.thread.take() {
43 // The loop checks the stop flag every `POLL_INTERVAL`, so this waits at most
44 // that long — bounded, not indefinite.
45 let _ = thread.join();
46 }
47 }
48}
49
50/// Rebuild `pipeline`'s outputs whenever a file under `watched` changes, until the
51/// returned handle is dropped.
52///
53/// `on_error` receives any failure from an individual rebuild. It exists rather than a
54/// log line because a library writing to its host's stderr uninvited is a surprise, and
55/// rather than an error queue because a queue nobody drains grows without bound. A
56/// rebuild failure must reach *somewhere*: silently swallowing it leaves the output dir
57/// holding stale bytes while the caller believes it is current.
58pub(crate) fn start(
59 pipeline: SourcePipeline,
60 watched: Vec<PathBuf>,
61 mut on_error: impl FnMut(BuildError) + Send + 'static,
62) -> WatchHandle {
63 let stop = Arc::new(AtomicBool::new(false));
64 let thread_stop = Arc::clone(&stop);
65
66 // The baseline is taken *here*, before the thread starts, so that once this function
67 // returns every subsequent write is guaranteed to be seen as a change. Taking it
68 // inside the thread instead leaves a window in which a caller who edits a file
69 // immediately has that edit absorbed into the baseline and silently never built —
70 // a race that is invisible in casual use and reliable under a script.
71 let mut seen = snapshot_mtimes(&watched);
72
73 let thread = std::thread::spawn(move || {
74 while !thread_stop.load(Ordering::Relaxed) {
75 std::thread::sleep(POLL_INTERVAL);
76 if thread_stop.load(Ordering::Relaxed) {
77 break;
78 }
79
80 let current = snapshot_mtimes(&watched);
81 for (path, mtime) in ¤t {
82 let unchanged = seen.get(path).is_some_and(|previous| previous == mtime);
83 if unchanged {
84 continue;
85 }
86 let change_type = ChangeType::from_path(path);
87 if let Err(e) = pipeline.process_change(path, &change_type) {
88 on_error(BuildError::Build(e));
89 }
90 }
91 seen = current;
92 }
93 });
94
95 WatchHandle {
96 stop,
97 thread: Some(thread),
98 }
99}
100
101/// Modification time of every file under every path in `roots`.
102///
103/// Unreadable entries are skipped rather than reported: a file that cannot be stat'd this
104/// pass is either gone or being written, and both resolve themselves by the next one.
105fn snapshot_mtimes(roots: &[PathBuf]) -> HashMap<PathBuf, SystemTime> {
106 let mut mtimes = HashMap::new();
107 for root in roots {
108 for path in walk_files(root) {
109 if let Ok(mtime) = std::fs::metadata(&path).and_then(|meta| meta.modified()) {
110 mtimes.insert(path, mtime);
111 }
112 }
113 }
114 mtimes
115}
116
117/// Every file under `dir`, recursively.
118///
119/// Bounded by the filesystem: directories are pushed onto a stack and popped until none
120/// remain, and a directory tree is finite. Unreadable directories are skipped so one
121/// permission error does not blind the whole watch.
122fn walk_files(dir: &Path) -> Vec<PathBuf> {
123 let mut files = Vec::new();
124 let mut pending = vec![dir.to_path_buf()];
125
126 while let Some(current) = pending.pop() {
127 let Ok(entries) = std::fs::read_dir(¤t) else {
128 continue;
129 };
130 for entry in entries.flatten() {
131 let path = entry.path();
132 match entry.file_type() {
133 Ok(file_type) if file_type.is_dir() => pending.push(path),
134 Ok(_) => files.push(path),
135 Err(_) => {}
136 }
137 }
138 }
139
140 files
141}
142
143#[cfg(test)]
144#[path = "../tests/unit/watch.rs"]
145mod tests;