Skip to main content

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 &current {
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(&current) 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;