krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit 951aa728e2

951aa728e21b6f09a832c049e74a7b3181482404

parent: 1429684118

Unsigned ci/pages: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-18 20:22 UTC

watch: ignore filesystem access events (!9)

inotify's watch mask includes `IN_OPEN`, so reading a file raises an event on it — and a build reads every source file. Each rebuild triggered the next, as fast as the machine could build; under `orgo serve` the browser reloads every few milliseconds. macOS and Windows report no access events, so only Linux was affected.

`is_change` drops `EventKind::Access(_)` except `Close(Write)`, which follows a write rather than a read. Non-access kinds are kept, including the backends' catch-all `Any` — FSEvents and the Windows backend report unclassified changes that way.

The end-to-end test now counts rebuilds through `run_with` instead of watching `syntax.css`'s mtime, so "a quiet tree builds nothing" covers the build's own reads as well as its writes.

Verified on the Linux runner: build 43 (the test alone, no fix) fails with 12 rebuilds in 1.5s of an idle tree; build 45 (test + fix) passes. Build 46, triggered on main for a control, passes with the old mtime assertion — that check was blind to the loop, which is why CI stayed green.

Reported by a user against `orgo serve`.

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
21use anyhow::{Context, Result}; 24use anyhow::{Context, Result};
22use camino::{Utf8Path, Utf8PathBuf}; 25use camino::{Utf8Path, Utf8PathBuf};
23use notify::{Config as NotifyConfig, RecursiveMode, Watcher}; 26use notify::event::{AccessKind, AccessMode};
27use notify::{Config as NotifyConfig, EventKind, RecursiveMode, Watcher};
24 28
25use crate::site::{build_site, BuildOptions}; 29use 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.
275pub 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.
262fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> { 285fn 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
8use std::sync::atomic::{AtomicU32, Ordering}; 8use std::sync::atomic::{AtomicU32, Ordering};
9use std::sync::Arc;
9use std::time::{Duration, Instant}; 10use std::time::{Duration, Instant};
10 11
11use camino::{Utf8Path, Utf8PathBuf}; 12use 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]
139fn 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]
138fn watching_rebuilds_the_site_when_a_source_file_changes() { 178fn 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