Commit 951aa728e2
Unsigned ci/pages: success ci/test: success
Layout: unified · split
src/watch.rs +29 −3
| @@ -4,14 +4,17 @@ | |||
| 4 | //! compare mtimes. Native events cost nothing while nothing happens, and arrive in | 4 | //! compare mtimes. Native events cost nothing while nothing happens, and arrive in |
| 5 | //! milliseconds when something does. | 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 | //! 1. **Not watching our own output.** `orgo watch . -o _site` puts the output inside | 9 | //! 1. **Not watching our own output.** `orgo watch . -o _site` puts the output inside |
| 10 | //! the source. Rebuilding writes files, writing files raises events, and events | 10 | //! the source. Rebuilding writes files, writing files raises events, and events |
| 11 | //! trigger a rebuild — a loop that never stops and never idles. [`ChangeFilter`] is | 11 | //! trigger a rebuild — a loop that never stops and never idles. [`ChangeFilter`] is |
| 12 | //! what prevents it, and it is a pure function precisely so it can be tested without | 12 | //! what prevents it, and it is a pure function precisely so it can be tested without |
| 13 | //! a filesystem. | 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 | //! temp file, rename it over the original, and touch the directory. Rebuilding per | 18 | //! temp file, rename it over the original, and touch the directory. Rebuilding per |
| 16 | //! event would rebuild several times per save. | 19 | //! event would rebuild several times per save. |
| 17 | 20 | ||
| @@ -20,7 +23,8 @@ use std::time::Duration; | |||
| 20 | 23 | ||
| 21 | use anyhow::{Context, Result}; | 24 | use anyhow::{Context, Result}; |
| 22 | use camino::{Utf8Path, Utf8PathBuf}; | 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 | use crate::site::{build_site, BuildOptions}; | 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 | /// The platform's native watcher, falling back to polling where that is unavailable — | 282 | /// The platform's native watcher, falling back to polling where that is unavailable — |
| 260 | /// some network and container filesystems have no event API, and `watch` failing outright | 283 | /// some network and container filesystems have no event API, and `watch` failing outright |
| 261 | /// there would be worse than being slow. | 284 | /// there would be worse than being slow. |
| 262 | fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> { | 285 | fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> { |
| 263 | let handler = move |result: notify::Result<notify::Event>| { | 286 | let handler = move |result: notify::Result<notify::Event>| { |
| 264 | if let Ok(event) = result { | 287 | if let Ok(event) = result { |
| 288 | if !is_change(&event.kind) { | ||
| 289 | return; | ||
| 290 | } | ||
| 265 | let paths: Vec<Utf8PathBuf> = event | 291 | let paths: Vec<Utf8PathBuf> = event |
| 266 | .paths | 292 | .paths |
| 267 | .into_iter() | 293 | .into_iter() |
tests/watch.rs +72 −30
| @@ -6,6 +6,7 @@ | |||
| 6 | //! guarantee testable without waiting on a filesystem. | 6 | //! guarantee testable without waiting on a filesystem. |
| 7 | 7 | ||
| 8 | use std::sync::atomic::{AtomicU32, Ordering}; | 8 | use std::sync::atomic::{AtomicU32, Ordering}; |
| 9 | use std::sync::Arc; | ||
| 9 | use std::time::{Duration, Instant}; | 10 | use std::time::{Duration, Instant}; |
| 10 | 11 | ||
| 11 | use camino::{Utf8Path, Utf8PathBuf}; | 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 | // End to end | 171 | // End to end |
| 133 | // --------------------------------------------------------------------------- | 172 | // --------------------------------------------------------------------------- |
| 134 | 173 | ||
| 135 | /// Drive the real watcher against a real edit. Timing-dependent by nature, so it polls | 174 | /// Drive the real watcher against a real edit, then hold still. Timing-dependent by |
| 136 | /// for the expected result with a generous ceiling rather than sleeping a fixed amount. | 175 | /// nature, so it polls for the expected result with a generous ceiling rather than |
| 176 | /// sleeping a fixed amount. | ||
| 137 | #[test] | 177 | #[test] |
| 138 | fn watching_rebuilds_the_site_when_a_source_file_changes() { | 178 | fn watching_rebuilds_the_site_when_a_source_file_changes() { |
| 139 | let root = tmpdir("watchrun"); | 179 | let root = tmpdir("watchrun"); |
| @@ -147,9 +187,14 @@ fn watching_rebuilds_the_site_when_a_source_file_changes() { | |||
| 147 | .unwrap() | 187 | .unwrap() |
| 148 | .contains("First version.")); | 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 | let handle = std::thread::spawn(move || { | 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 | // The edit is repeated rather than made once after a fixed head start. The watcher | 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 | // defect worth failing a build over. (Found by the first CI run on Linux, where a | 205 | // defect worth failing a build over. (Found by the first CI run on Linux, where a |
| 161 | // 300ms head start was not enough and macOS had never noticed.) | 206 | // 300ms head start was not enough and macOS had never noticed.) |
| 162 | let deadline = Instant::now() + Duration::from_secs(30); | 207 | let deadline = Instant::now() + Duration::from_secs(30); |
| 163 | let mut rebuilt = false; | ||
| 164 | let mut wrote_at: Option<Instant> = None; | 208 | let mut wrote_at: Option<Instant> = None; |
| 165 | while Instant::now() < deadline { | 209 | while rebuilds.load(Ordering::Relaxed) == 0 && Instant::now() < deadline { |
| 166 | if wrote_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) { | 210 | if wrote_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) { |
| 167 | std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap(); | 211 | std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap(); |
| 168 | wrote_at = Some(Instant::now()); | 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 | std::thread::sleep(Duration::from_millis(50)); | 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 | 224 | // Nothing is editing the tree now, so nothing more should be built. Two of the |
| 182 | // those are not filtered out, watch spins forever. | 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 | 227 | // 1. Its writes. The output lives inside the source, so they raise events — |
| 185 | // build leaves an unchanged page alone, so `index.html` holds still even *during* a | 228 | // [`ChangeFilter`] drops them. |
| 186 | // runaway loop — an assertion on it passes whether or not the filter works, which is | 229 | // 2. Its reads. A build reads every source file, and inotify reports opens, so a |
| 187 | // exactly what it did before this comment existed. `syntax.css` is rewritten on | 230 | // watcher that treats an access as a change rebuilds at full speed. Reported |
| 188 | // every build, so its mtime is a direct record of how many builds have run. | 231 | // against `orgo serve`, where every rebuild reloads the browser. |
| 189 | // Long enough for the rebuild from the last repeated write to have landed before the | 232 | // |
| 190 | // first reading, or this measures that instead of a feedback loop. | 233 | // The first sleep lets the rebuild from the last repeated write land, or this |
| 191 | let stylesheet = out.join("syntax.css"); | 234 | // measures that instead of a feedback loop. |
| 235 | std::thread::sleep(Duration::from_millis(1500)); | ||
| 236 | let settled = rebuilds.load(Ordering::Relaxed); | ||
| 192 | std::thread::sleep(Duration::from_millis(1500)); | 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 | assert_eq!( | 238 | assert_eq!( |
| 197 | first, second, | 239 | settled, |
| 198 | "the build's own writes must not feed back in as changes — watch is rebuilding \ | 240 | rebuilds.load(Ordering::Relaxed), |
| 199 | in a loop" | 241 | "a quiet tree must produce no rebuilds — watch is rebuilding in a loop" |
| 200 | ); | 242 | ); |
| 201 | 243 | ||
| 202 | drop(handle); // the watcher thread ends with the process | 244 | drop(handle); // the watcher thread ends with the process |