Commit 951aa728e2
Unsigned ci/pages: success ci/test: success
Layout: unified · split
src/watch.rs +29 −3
| @@ -4,14 +4,17 @@ | ||
| 4 | 4 | //! compare mtimes. Native events cost nothing while nothing happens, and arrive in |
| 5 | 5 | //! milliseconds when something does. |
| 6 | 6 | //! |
| 7 | //! Two things matter more than the watching itself: | |
| 7 | //! Three things matter more than the watching itself: | |
| 8 | 8 | //! |
| 9 | 9 | //! 1. **Not watching our own output.** `orgo watch . -o _site` puts the output inside |
| 10 | 10 | //! the source. Rebuilding writes files, writing files raises events, and events |
| 11 | 11 | //! trigger a rebuild — a loop that never stops and never idles. [`ChangeFilter`] is |
| 12 | 12 | //! what prevents it, and it is a pure function precisely so it can be tested without |
| 13 | 13 | //! a filesystem. |
| 14 | //! 2. **Debouncing.** Saving a file in an editor is rarely one event: editors write a | |
| 14 | //! 2. **Not treating a read as a change.** A build reads every source file, and inotify | |
| 15 | //! reports opens. [`is_change`] is what keeps that from closing the same loop by a | |
| 16 | //! different route. | |
| 17 | //! 3. **Debouncing.** Saving a file in an editor is rarely one event: editors write a | |
| 15 | 18 | //! temp file, rename it over the original, and touch the directory. Rebuilding per |
| 16 | 19 | //! event would rebuild several times per save. |
| 17 | 20 | |
| @@ -20,7 +23,8 @@ use std::time::Duration; | ||
| 20 | 23 | |
| 21 | 24 | use anyhow::{Context, Result}; |
| 22 | 25 | use camino::{Utf8Path, Utf8PathBuf}; |
| 23 | use notify::{Config as NotifyConfig, RecursiveMode, Watcher}; | |
| 26 | use notify::event::{AccessKind, AccessMode}; | |
| 27 | use notify::{Config as NotifyConfig, EventKind, RecursiveMode, Watcher}; | |
| 24 | 28 | |
| 25 | 29 | use crate::site::{build_site, BuildOptions}; |
| 26 | 30 | |
| @@ -256,12 +260,34 @@ fn summarize(changed: &[Utf8PathBuf]) -> String { | ||
| 256 | 260 | } |
| 257 | 261 | } |
| 258 | 262 | |
| 263 | /// Does this event report a change to the tree, rather than someone reading it? | |
| 264 | /// | |
| 265 | /// inotify's watch mask includes `IN_OPEN`, so reading a file raises an event on it — and | |
| 266 | /// a build reads every source file. Forwarding those makes each rebuild the trigger for | |
| 267 | /// the next, at whatever rate the machine can build. macOS and Windows report no access | |
| 268 | /// events, which is why this only showed up on Linux. | |
| 269 | /// | |
| 270 | /// `Access(Close(Write))` is the exception: it follows a write, not a read, and the only | |
| 271 | /// files a build writes are its own output, which [`ChangeFilter`] already drops. | |
| 272 | /// Everything that is not an access is kept, the backends' catch-all `Any` included — | |
| 273 | /// FSEvents and the Windows backend both report changes they cannot classify that way, so | |
| 274 | /// an allow-list of `Create`/`Modify`/`Remove` would drop real edits. | |
| 275 | pub fn is_change(kind: &EventKind) -> bool { | |
| 276 | match kind { | |
| 277 | EventKind::Access(access) => matches!(access, AccessKind::Close(AccessMode::Write)), | |
| 278 | _ => true, | |
| 279 | } | |
| 280 | } | |
| 281 | ||
| 259 | 282 | /// The platform's native watcher, falling back to polling where that is unavailable — |
| 260 | 283 | /// some network and container filesystems have no event API, and `watch` failing outright |
| 261 | 284 | /// there would be worse than being slow. |
| 262 | 285 | fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> { |
| 263 | 286 | let handler = move |result: notify::Result<notify::Event>| { |
| 264 | 287 | if let Ok(event) = result { |
| 288 | if !is_change(&event.kind) { | |
| 289 | return; | |
| 290 | } | |
| 265 | 291 | let paths: Vec<Utf8PathBuf> = event |
| 266 | 292 | .paths |
| 267 | 293 | .into_iter() |
tests/watch.rs +72 −30
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | //! guarantee testable without waiting on a filesystem. |
| 7 | 7 | |
| 8 | 8 | use std::sync::atomic::{AtomicU32, Ordering}; |
| 9 | use std::sync::Arc; | |
| 9 | 10 | use std::time::{Duration, Instant}; |
| 10 | 11 | |
| 11 | 12 | use camino::{Utf8Path, Utf8PathBuf}; |
| @@ -128,12 +129,51 @@ fn absolute_event_paths_are_reduced_to_a_sorted_unique_set() { | ||
| 128 | 129 | ); |
| 129 | 130 | } |
| 130 | 131 | |
| 132 | // --------------------------------------------------------------------------- | |
| 133 | // Event kinds | |
| 134 | // --------------------------------------------------------------------------- | |
| 135 | ||
| 136 | /// A read is not a change. inotify reports opens, and a build reads every source file, so | |
| 137 | /// an access forwarded as a change makes each rebuild trigger the next. | |
| 138 | #[test] | |
| 139 | fn access_events_do_not_count_as_changes() { | |
| 140 | use notify::event::{AccessKind, AccessMode, CreateKind, DataChange, ModifyKind, RemoveKind}; | |
| 141 | use notify::EventKind; | |
| 142 | use orgo::watch::is_change; | |
| 143 | ||
| 144 | for read in [ | |
| 145 | AccessKind::Any, | |
| 146 | AccessKind::Read, | |
| 147 | AccessKind::Open(AccessMode::Any), | |
| 148 | AccessKind::Open(AccessMode::Read), | |
| 149 | AccessKind::Close(AccessMode::Read), | |
| 150 | ] { | |
| 151 | assert!( | |
| 152 | !is_change(&EventKind::Access(read)), | |
| 153 | "{read:?} is a read, not a change" | |
| 154 | ); | |
| 155 | } | |
| 156 | ||
| 157 | assert!( | |
| 158 | is_change(&EventKind::Access(AccessKind::Close(AccessMode::Write))), | |
| 159 | "closing a file that was written to is a change" | |
| 160 | ); | |
| 161 | assert!(is_change(&EventKind::Create(CreateKind::File))); | |
| 162 | assert!(is_change(&EventKind::Modify(ModifyKind::Data(DataChange::Any)))); | |
| 163 | assert!(is_change(&EventKind::Remove(RemoveKind::File))); | |
| 164 | assert!( | |
| 165 | is_change(&EventKind::Any), | |
| 166 | "FSEvents and the Windows backend report unclassified changes this way" | |
| 167 | ); | |
| 168 | } | |
| 169 | ||
| 131 | 170 | // --------------------------------------------------------------------------- |
| 132 | 171 | // End to end |
| 133 | 172 | // --------------------------------------------------------------------------- |
| 134 | 173 | |
| 135 | /// Drive the real watcher against a real edit. Timing-dependent by nature, so it polls | |
| 136 | /// for the expected result with a generous ceiling rather than sleeping a fixed amount. | |
| 174 | /// Drive the real watcher against a real edit, then hold still. Timing-dependent by | |
| 175 | /// nature, so it polls for the expected result with a generous ceiling rather than | |
| 176 | /// sleeping a fixed amount. | |
| 137 | 177 | #[test] |
| 138 | 178 | fn watching_rebuilds_the_site_when_a_source_file_changes() { |
| 139 | 179 | let root = tmpdir("watchrun"); |
| @@ -147,9 +187,14 @@ fn watching_rebuilds_the_site_when_a_source_file_changes() { | ||
| 147 | 187 | .unwrap() |
| 148 | 188 | .contains("First version.")); |
| 149 | 189 | |
| 150 | let (src_t, out_t) = (src.clone(), out.clone()); | |
| 190 | // Count rebuilds rather than reading an output file's mtime: a quiet watcher is | |
| 191 | // "no more rebuilds", and that is what the counter says directly. | |
| 192 | let rebuilds = Arc::new(AtomicU32::new(0)); | |
| 193 | let (src_t, out_t, counter) = (src.clone(), out.clone(), Arc::clone(&rebuilds)); | |
| 151 | 194 | let handle = std::thread::spawn(move || { |
| 152 | let _ = orgo::watch::run(&src_t, &out_t, &BuildOptions::default()); | |
| 195 | let _ = orgo::watch::run_with(&src_t, &out_t, &BuildOptions::default(), |_| { | |
| 196 | counter.fetch_add(1, Ordering::Relaxed); | |
| 197 | }); | |
| 153 | 198 | }); |
| 154 | 199 | |
| 155 | 200 | // The edit is repeated rather than made once after a fixed head start. The watcher |
| @@ -160,43 +205,40 @@ fn watching_rebuilds_the_site_when_a_source_file_changes() { | ||
| 160 | 205 | // defect worth failing a build over. (Found by the first CI run on Linux, where a |
| 161 | 206 | // 300ms head start was not enough and macOS had never noticed.) |
| 162 | 207 | let deadline = Instant::now() + Duration::from_secs(30); |
| 163 | let mut rebuilt = false; | |
| 164 | 208 | let mut wrote_at: Option<Instant> = None; |
| 165 | while Instant::now() < deadline { | |
| 209 | while rebuilds.load(Ordering::Relaxed) == 0 && Instant::now() < deadline { | |
| 166 | 210 | if wrote_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) { |
| 167 | 211 | std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap(); |
| 168 | 212 | wrote_at = Some(Instant::now()); |
| 169 | 213 | } |
| 170 | if std::fs::read_to_string(out.join("index.html")) | |
| 171 | .map(|h| h.contains("Second version.")) | |
| 172 | .unwrap_or(false) | |
| 173 | { | |
| 174 | rebuilt = true; | |
| 175 | break; | |
| 176 | } | |
| 177 | 214 | std::thread::sleep(Duration::from_millis(50)); |
| 178 | 215 | } |
| 179 | assert!(rebuilt, "an edit should trigger a rebuild within 30s"); | |
| 216 | assert!( | |
| 217 | rebuilds.load(Ordering::Relaxed) > 0, | |
| 218 | "an edit should trigger a rebuild within 30s" | |
| 219 | ); | |
| 220 | assert!(std::fs::read_to_string(out.join("index.html")) | |
| 221 | .unwrap() | |
| 222 | .contains("Second version.")); | |
| 180 | 223 | |
| 181 | // The output lives inside the source, so the rebuild's own writes raised events. If | |
| 182 | // those are not filtered out, watch spins forever. | |
| 224 | // Nothing is editing the tree now, so nothing more should be built. Two of the | |
| 225 | // rebuild's own side effects would otherwise feed back in as changes: | |
| 183 | 226 | // |
| 184 | // The file to watch for that is `syntax.css`, not `index.html`. The incremental | |
| 185 | // build leaves an unchanged page alone, so `index.html` holds still even *during* a | |
| 186 | // runaway loop — an assertion on it passes whether or not the filter works, which is | |
| 187 | // exactly what it did before this comment existed. `syntax.css` is rewritten on | |
| 188 | // every build, so its mtime is a direct record of how many builds have run. | |
| 189 | // Long enough for the rebuild from the last repeated write to have landed before the | |
| 190 | // first reading, or this measures that instead of a feedback loop. | |
| 191 | let stylesheet = out.join("syntax.css"); | |
| 227 | // 1. Its writes. The output lives inside the source, so they raise events — | |
| 228 | // [`ChangeFilter`] drops them. | |
| 229 | // 2. Its reads. A build reads every source file, and inotify reports opens, so a | |
| 230 | // watcher that treats an access as a change rebuilds at full speed. Reported | |
| 231 | // against `orgo serve`, where every rebuild reloads the browser. | |
| 232 | // | |
| 233 | // The first sleep lets the rebuild from the last repeated write land, or this | |
| 234 | // measures that instead of a feedback loop. | |
| 235 | std::thread::sleep(Duration::from_millis(1500)); | |
| 236 | let settled = rebuilds.load(Ordering::Relaxed); | |
| 192 | 237 | std::thread::sleep(Duration::from_millis(1500)); |
| 193 | let first = std::fs::metadata(&stylesheet).unwrap().modified().unwrap(); | |
| 194 | std::thread::sleep(Duration::from_millis(1200)); | |
| 195 | let second = std::fs::metadata(&stylesheet).unwrap().modified().unwrap(); | |
| 196 | 238 | assert_eq!( |
| 197 | first, second, | |
| 198 | "the build's own writes must not feed back in as changes — watch is rebuilding \ | |
| 199 | in a loop" | |
| 239 | settled, | |
| 240 | rebuilds.load(Ordering::Relaxed), | |
| 241 | "a quiet tree must produce no rebuilds — watch is rebuilding in a loop" | |
| 200 | 242 | ); |
| 201 | 243 | |
| 202 | 244 | drop(handle); // the watcher thread ends with the process |