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 @@
44//! compare mtimes. Native events cost nothing while nothing happens, and arrive in
55//! milliseconds when something does.
66//!
7//! Two things matter more than the watching itself:
7//! Three things matter more than the watching itself:
88//!
99//! 1. **Not watching our own output.** `orgo watch . -o _site` puts the output inside
1010//! the source. Rebuilding writes files, writing files raises events, and events
1111//! trigger a rebuild — a loop that never stops and never idles. [`ChangeFilter`] is
1212//! what prevents it, and it is a pure function precisely so it can be tested without
1313//! 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
1518//! temp file, rename it over the original, and touch the directory. Rebuilding per
1619//! event would rebuild several times per save.
1720
@@ -20,7 +23,8 @@ use std::time::Duration;
2023
2124use anyhow::{Context, Result};
2225use camino::{Utf8Path, Utf8PathBuf};
23use notify::{Config as NotifyConfig, RecursiveMode, Watcher};
26use notify::event::{AccessKind, AccessMode};
27use notify::{Config as NotifyConfig, EventKind, RecursiveMode, Watcher};
2428
2529use crate::site::{build_site, BuildOptions};
2630
@@ -256,12 +260,34 @@ fn summarize(changed: &[Utf8PathBuf]) -> String {
256260 }
257261}
258262
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
259282/// The platform's native watcher, falling back to polling where that is unavailable —
260283/// some network and container filesystems have no event API, and `watch` failing outright
261284/// there would be worse than being slow.
262285fn make_watcher(tx: mpsc::Sender<Vec<Utf8PathBuf>>) -> Result<Box<dyn Watcher>> {
263286 let handler = move |result: notify::Result<notify::Event>| {
264287 if let Ok(event) = result {
288 if !is_change(&event.kind) {
289 return;
290 }
265291 let paths: Vec<Utf8PathBuf> = event
266292 .paths
267293 .into_iter()
tests/watch.rs +72 −30
@@ -6,6 +6,7 @@
66//! guarantee testable without waiting on a filesystem.
77
88use std::sync::atomic::{AtomicU32, Ordering};
9use std::sync::Arc;
910use std::time::{Duration, Instant};
1011
1112use camino::{Utf8Path, Utf8PathBuf};
@@ -128,12 +129,51 @@ fn absolute_event_paths_are_reduced_to_a_sorted_unique_set() {
128129 );
129130}
130131
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
131170// ---------------------------------------------------------------------------
132171// End to end
133172// ---------------------------------------------------------------------------
134173
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.
137177#[test]
138178fn watching_rebuilds_the_site_when_a_source_file_changes() {
139179 let root = tmpdir("watchrun");
@@ -147,9 +187,14 @@ fn watching_rebuilds_the_site_when_a_source_file_changes() {
147187 .unwrap()
148188 .contains("First version."));
149189
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));
151194 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 });
153198 });
154199
155200 // 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() {
160205 // defect worth failing a build over. (Found by the first CI run on Linux, where a
161206 // 300ms head start was not enough and macOS had never noticed.)
162207 let deadline = Instant::now() + Duration::from_secs(30);
163 let mut rebuilt = false;
164208 let mut wrote_at: Option<Instant> = None;
165 while Instant::now() < deadline {
209 while rebuilds.load(Ordering::Relaxed) == 0 && Instant::now() < deadline {
166210 if wrote_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(400)) {
167211 std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nSecond version.\n").unwrap();
168212 wrote_at = Some(Instant::now());
169213 }
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 }
177214 std::thread::sleep(Duration::from_millis(50));
178215 }
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."));
180223
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:
183226 //
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);
192237 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();
196238 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"
200242 );
201243
202244 drop(handle); // the watcher thread ends with the process