slipcase_open/watch.rs
1//! Watching the content directory, and what the events in it mean.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! **The watch is on the directory and never on the file**, which concept 6
7//! calls one of the three things that make write-back detection hard. A serious
8//! editor saves by writing a temporary sibling and renaming it over the target,
9//! so a watcher registered on the content file loses its handle on the first
10//! save and never fires again. `notify` will watch a directory on all three
11//! platforms, but only if it is asked to.
12//!
13//! ## The sibling signal
14//!
15//! Concept 6.1: the content directory holds one file, put there by this tool,
16//! so anything else appearing in it was created by the target application — a
17//! lock file, an autosave, a backup, a save in progress. Nothing here needs to
18//! know which, or what any of them are called, which is why there is no table
19//! of `~$name.docx` and `.~lock.name#` conventions to maintain and no
20//! application it fails to know about.
21//!
22//! Siblings present means the application is working in there, which process
23//! exit does not tell you. Siblings gone means it has cleaned up and has
24//! probably finished. It stays a heuristic in both directions: most
25//! read-oriented applications write no sibling at all, so an empty directory
26//! means nothing, and an application that leaves a backup behind for good never
27//! produces the cleaned-up signal. Both degrade to silence, which is the
28//! intended fallback and is why the session model does not rest on this.
29
30use std::path::Path;
31use std::sync::mpsc::{self, Receiver};
32use std::time::Duration;
33
34use notify::{RecommendedWatcher, RecursiveMode, Watcher as _};
35
36/// What happened in a content directory.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Change {
39 /// The content file itself was written, replaced, or removed. The
40 /// write-back trigger.
41 Content,
42 /// Something the target application made appeared beside it.
43 SiblingAppeared,
44 /// Something it had made went away.
45 SiblingWentAway,
46}
47
48/// What an event in the content directory means, given the content file's name.
49///
50/// Pure, so the rule is testable without a filesystem or a race. Paths are
51/// compared by their final component: `notify` reports absolute paths, and a
52/// rename within the directory arrives as paths that differ only there.
53///
54/// A rename over the content file produces events naming both the temporary
55/// sibling and the content file, and both are worth reporting — the first says
56/// the application is working, the second is the save.
57#[must_use]
58pub fn classify(content_name: &str, paths: &[&Path], kind: EventKind) -> Vec<Change> {
59 paths
60 .iter()
61 .map(|p| {
62 let is_content = p.file_name().is_some_and(|n| n == content_name);
63 match (is_content, kind) {
64 (true, _) => Change::Content,
65 (false, EventKind::Gone) => Change::SiblingWentAway,
66 (false, _) => Change::SiblingAppeared,
67 }
68 })
69 .collect()
70}
71
72/// The shape of an event, reduced to what the rule above needs.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum EventKind {
75 /// Created, written, or renamed into place.
76 Touched,
77 /// Removed, or renamed away.
78 Gone,
79}
80
81impl EventKind {
82 /// Reduce one of `notify`'s events, or discard it.
83 ///
84 /// **Reading a file is not changing it.** A plain read of the watched
85 /// content file emits `Access(Open(Any))` on Linux — measured on
86 /// 2026-08-30 — and treating that as a save makes every reader of the
87 /// content file a source of spurious write-backs. Anything that reads it
88 /// counts: the write-back itself opens the content file, and so does
89 /// `recover::state`, which is called once per session by `sessions`.
90 /// Running `sessions` in a loop beside an open session produced a repack
91 /// per poll before this arm existed.
92 ///
93 /// The repacks are invisible from outside, which is why this is a rule and
94 /// a test rather than something anyone would notice: each one writes the
95 /// same bytes, so the container stays right and only the work is wrong.
96 ///
97 /// The exception is a close after writing, which is the one access event
98 /// that means a save finished. Linux reports it and the other platforms do
99 /// not, so it is a signal to take where it is offered rather than one to
100 /// depend on.
101 ///
102 /// `Any` and `Other` stay a touch. An unrecognised event in this directory
103 /// is still something happening in it, and the cost of treating one as a
104 /// save is a repack that writes what is already there.
105 fn of(kind: notify::EventKind) -> Option<Self> {
106 use notify::event::{AccessKind, AccessMode, ModifyKind, RenameMode};
107 match kind {
108 // A removal, and the half of a rename that names where the file
109 // was. The other half names where it went, which is a touch.
110 notify::EventKind::Remove(_)
111 | notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)) => Some(Self::Gone),
112 notify::EventKind::Access(AccessKind::Close(AccessMode::Write)) => Some(Self::Touched),
113 notify::EventKind::Access(_) => None,
114 _ => Some(Self::Touched),
115 }
116 }
117}
118
119/// A watch on one content directory.
120///
121/// Holds the platform watcher, which stops when this is dropped.
122pub struct Watch {
123 /// `Option` so [`Drop`] can drop the watcher and *then* wait for the
124 /// platform to finish stopping it.
125 watcher: Option<RecommendedWatcher>,
126 changes: Receiver<Change>,
127 /// Where Windows reports that a watch has finished stopping.
128 ///
129 /// `notify`'s own constructor throws this receiver away, which is why the
130 /// stop is unobservable through the ordinary API and why this builds the
131 /// watcher the long way round. [`Drop`] says what it is for.
132 #[cfg(windows)]
133 stopped: Receiver<notify::windows::MetaEvent>,
134}
135
136/// How long [`Watch::drop`] will wait for a stop to finish.
137///
138/// A stop that has gone wrong should not become a hang of its own, which is
139/// the defect this is here to avoid rather than to relocate. A stop that is
140/// working takes microseconds, so a wait this long is only ever paid by one
141/// that is not.
142#[cfg(windows)]
143const STOP_WAIT: Duration = Duration::from_secs(5);
144
145impl Watch {
146 /// Watch `dir` for changes to `content_name` and to anything beside it.
147 ///
148 /// Non-recursive: the content directory has no subdirectories of this
149 /// tool's making, and an application that creates one has still created a
150 /// sibling, which is the signal either way.
151 ///
152 /// # Errors
153 ///
154 /// Where the platform watcher cannot be created or cannot watch `dir`.
155 pub fn on(dir: &Path, content_name: &str) -> notify::Result<Self> {
156 let (tx, changes) = mpsc::channel();
157 let content_name = content_name.to_string();
158 let handler = move |event: notify::Result<notify::Event>| {
159 let Ok(event) = event else {
160 // A dropped or errored event is not a reason to tear down the
161 // watch. Concept 6.2 exists because detection is unreliable,
162 // and the session close is the backstop for everything this
163 // misses.
164 return;
165 };
166 let Some(kind) = EventKind::of(event.kind) else {
167 return;
168 };
169 let paths: Vec<&Path> = event.paths.iter().map(AsRef::as_ref).collect();
170 for change in classify(&content_name, &paths, kind) {
171 // A closed receiver means the session is gone and there is
172 // nobody to tell.
173 if tx.send(change).is_err() {
174 return;
175 }
176 }
177 };
178
179 // On Windows the watcher is built through `create` rather than
180 // `recommended_watcher`, for the one thing `recommended_watcher` throws
181 // away: the channel the backend reports `SingleWatchComplete` on.
182 // Without it a stop cannot be waited for, and `Drop` has to be able to
183 // wait. Everywhere else the ordinary constructor is right.
184 #[cfg(windows)]
185 let (mut watcher, stopped) = {
186 let (meta_tx, stopped) = mpsc::channel();
187 let handler: std::sync::Arc<std::sync::Mutex<dyn notify::EventHandler>> =
188 std::sync::Arc::new(std::sync::Mutex::new(handler));
189 let watcher = notify::windows::ReadDirectoryChangesWatcher::create(handler, meta_tx)?;
190 (watcher, stopped)
191 };
192 #[cfg(not(windows))]
193 let mut watcher = notify::recommended_watcher(handler)?;
194
195 watcher.watch(dir, RecursiveMode::NonRecursive)?;
196 Ok(Self {
197 watcher: Some(watcher),
198 changes,
199 #[cfg(windows)]
200 stopped,
201 })
202 }
203
204 /// Every change that has arrived, without waiting.
205 pub fn drain(&self) -> impl Iterator<Item = Change> + '_ {
206 self.changes.try_iter()
207 }
208
209 /// Wait up to `within` for the next change.
210 #[must_use]
211 pub fn next_change(&self, within: Duration) -> Option<Change> {
212 self.changes.recv_timeout(within).ok()
213 }
214}
215
216/// Whether the target application has anything of its own in the content
217/// directory.
218///
219/// Asked of the directory rather than tracked from events, because events can
220/// be missed and the answer has to be right at the moment somebody is deciding
221/// whether to close a session (concept 6.2).
222///
223/// # Errors
224///
225/// Where the directory cannot be read.
226pub fn siblings_present(dir: &Path, content_name: &str) -> std::io::Result<bool> {
227 for entry in std::fs::read_dir(dir)? {
228 if entry?.file_name() != *content_name {
229 return Ok(true);
230 }
231 }
232 Ok(false)
233}
234
235/// **The stop is waited for, and this is the fix for a measured hang.**
236///
237/// `notify`'s watcher drop is fire-and-forget: it posts `Action::Stop`, wakes
238/// its server thread and returns, leaving that thread inside `stop_watch`.
239/// Whatever removes the watched directory next therefore races a watch that is
240/// still stopping, and on Windows that pair deadlocks — `stop_watch` waits
241/// `INFINITE` for a semaphore its own completion routine does not post when it
242/// re-arms `ReadDirectoryChangesW`, and the re-armed read does not return while
243/// a `remove_dir_all` is walking the same directory.
244///
245/// Measured 2026-09-07 before this existed: a diagnostic that drops a watch and
246/// then removes the directory wedged 9 runs in 20, one instance parked with
247/// zero CPU for over ten minutes; `Opened::close`, which removes first and
248/// drops after, wedged past a 300-second timeout with the same two stacks.
249/// `docs/windows-save-test-hang.md` has both, and the ordering alone was never
250/// the cure: the 9-in-20 case already dropped the watch first.
251///
252/// So the watcher goes, and then this waits for the backend to say the watch
253/// has actually stopped, which is the guarantee the caller needs before
254/// removing anything. [`STOP_WAIT`] bounds the wait so that a stop which never
255/// finishes is a delay rather than a second hang.
256impl Drop for Watch {
257 fn drop(&mut self) {
258 drop(self.watcher.take());
259
260 #[cfg(windows)]
261 {
262 // The same channel carries `WatcherAwakened`, so this reads until
263 // the completion it wants, the sender goes, or the clock runs out.
264 let deadline = std::time::Instant::now() + STOP_WAIT;
265 loop {
266 let left = deadline.saturating_duration_since(std::time::Instant::now());
267 if left.is_zero() {
268 break;
269 }
270 match self.stopped.recv_timeout(left) {
271 Ok(notify::windows::MetaEvent::SingleWatchComplete) | Err(_) => break,
272 Ok(_) => {}
273 }
274 }
275 }
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::{classify, siblings_present, Change, EventKind, Watch};
282 use std::path::{Path, PathBuf};
283 use std::time::Duration;
284
285 fn at(names: &[&str]) -> Vec<PathBuf> {
286 names
287 .iter()
288 .map(|n| Path::new("/s/content").join(n))
289 .collect()
290 }
291
292 fn refs(paths: &[PathBuf]) -> Vec<&Path> {
293 paths.iter().map(AsRef::as_ref).collect()
294 }
295
296 #[test]
297 fn writing_the_content_file_is_the_write_back_trigger() {
298 let p = at(&["report.pdf"]);
299 assert_eq!(
300 classify("report.pdf", &refs(&p), EventKind::Touched),
301 [Change::Content]
302 );
303 }
304
305 #[test]
306 fn anything_else_appearing_is_the_application_working() {
307 // No table of lock file conventions. The directory held one file, this
308 // tool put it there, so whatever this is came from the editor.
309 for name in [
310 "~$report.docx",
311 ".~lock.report.pdf#",
312 "report.pdf.tmp",
313 "4919",
314 ] {
315 let p = at(&[name]);
316 assert_eq!(
317 classify("report.pdf", &refs(&p), EventKind::Touched),
318 [Change::SiblingAppeared],
319 "{name}"
320 );
321 }
322 }
323
324 #[test]
325 fn a_sibling_going_away_is_the_application_finishing() {
326 let p = at(&["~$report.docx"]);
327 assert_eq!(
328 classify("report.pdf", &refs(&p), EventKind::Gone),
329 [Change::SiblingWentAway]
330 );
331 }
332
333 #[test]
334 fn the_content_file_going_away_is_still_the_content_file() {
335 // A rename over it arrives as the content file being replaced, and an
336 // application that deletes and rewrites is doing a save in two steps.
337 // Either way the container should be asked to catch up.
338 let p = at(&["report.pdf"]);
339 assert_eq!(
340 classify("report.pdf", &refs(&p), EventKind::Gone),
341 [Change::Content]
342 );
343 }
344
345 #[test]
346 fn a_rename_naming_both_paths_reports_both() {
347 // The atomic save: a temporary sibling renamed over the target. The
348 // sibling says the application is working and the content file is the
349 // save, and dropping either would lose one of the two things the watch
350 // is for.
351 let p = at(&["report.pdf.tmp", "report.pdf"]);
352 assert_eq!(
353 classify("report.pdf", &refs(&p), EventKind::Touched),
354 [Change::SiblingAppeared, Change::Content]
355 );
356 }
357
358 #[test]
359 fn a_content_file_named_like_a_lock_file_is_still_the_content_file() {
360 // SPEC 2.3 permits any plain filename. Matching by name and not by
361 // shape is what keeps this true.
362 let p = at(&["~$report.docx"]);
363 assert_eq!(
364 classify("~$report.docx", &refs(&p), EventKind::Touched),
365 [Change::Content]
366 );
367 }
368
369 #[test]
370 fn siblings_are_asked_of_the_directory_rather_than_remembered() {
371 let tmp = tempfile::tempdir().unwrap();
372 std::fs::write(tmp.path().join("report.pdf"), b"x").unwrap();
373 assert!(!siblings_present(tmp.path(), "report.pdf").unwrap());
374
375 std::fs::write(tmp.path().join("~$report.pdf"), b"").unwrap();
376 assert!(siblings_present(tmp.path(), "report.pdf").unwrap());
377
378 std::fs::remove_file(tmp.path().join("~$report.pdf")).unwrap();
379 assert!(!siblings_present(tmp.path(), "report.pdf").unwrap());
380 }
381
382 #[test]
383 fn reading_the_content_file_is_not_a_change_to_it() {
384 // Write-back opens the content file to read it, which inotify reports
385 // as an access on the watched file. Treating that as a save makes the
386 // write-back its own trigger: measured on 2026-08-30, one edit produced
387 // three repacks and would have produced more had the session stayed
388 // open.
389 //
390 // **The read has to be told apart from the setup, and only after the
391 // watch is quiet.** FSEvents is path-based and replays: registering a
392 // watch delivers the events that just happened to the directory,
393 // including the tempdir appearing and the `write` below, at the
394 // platform's own latency rather than before this returns. Measured on
395 // an Apple silicon runner 2026-09-07, the third run in a loop: the
396 // setup arrived as `[SiblingAppeared, Content, Content]` inside the
397 // window and read as the read. inotify and ReadDirectoryChangesW
398 // deliver only what follows registration and never showed it. So the
399 // watch is drained until it goes quiet, which absorbs the replay on
400 // every platform, and only what the read then produces is measured —
401 // which is nothing: probed five times on that platform, a settled read
402 // produced no `Content`, because a read is `Access` and `EventKind::of`
403 // discards it. The earlier version had no settle and asserted against
404 // the whole window, so it was measuring the notifier.
405 let tmp = tempfile::tempdir().unwrap();
406 let content_path = tmp.path().join("report.pdf");
407 std::fs::write(&content_path, b"first").unwrap();
408
409 let watch = Watch::on(tmp.path(), "report.pdf").unwrap();
410
411 // Quiet is nothing for 400ms, capped so a notifier that never falls
412 // silent cannot hang the suite. The replay is what is being waited out.
413 let cap = std::time::Instant::now() + Duration::from_secs(5);
414 while watch.next_change(Duration::from_millis(400)).is_some()
415 && std::time::Instant::now() < cap
416 {}
417
418 let _ = std::fs::read(&content_path).unwrap();
419
420 let deadline = std::time::Instant::now() + Duration::from_secs(2);
421 let mut seen = Vec::new();
422 while std::time::Instant::now() < deadline {
423 if let Some(c) = watch.next_change(Duration::from_millis(100)) {
424 seen.push(c);
425 }
426 }
427 assert!(
428 !seen.contains(&Change::Content),
429 "reading the content file was reported as a change: {seen:?}"
430 );
431 }
432
433 #[test]
434 fn a_real_atomic_save_reaches_the_watch() {
435 // The one test that goes through the platform. It saves the way a
436 // serious editor does — write a temporary sibling, rename over the
437 // target — which is the case a watch registered on the file would miss
438 // entirely.
439 let tmp = tempfile::tempdir().unwrap();
440 let content_path = tmp.path().join("report.pdf");
441 std::fs::write(&content_path, b"first").unwrap();
442
443 let watch = Watch::on(tmp.path(), "report.pdf").unwrap();
444
445 let scratch = tmp.path().join("report.pdf.tmp");
446 std::fs::write(&scratch, b"second").unwrap();
447 std::fs::rename(&scratch, &content_path).unwrap();
448
449 // Generously, because this is at the platform's pace and not ours.
450 let mut seen = Vec::new();
451 let deadline = std::time::Instant::now() + Duration::from_secs(10);
452 while std::time::Instant::now() < deadline && !seen.contains(&Change::Content) {
453 if let Some(c) = watch.next_change(Duration::from_millis(250)) {
454 seen.push(c);
455 }
456 }
457 assert!(
458 seen.contains(&Change::Content),
459 "the save never arrived: {seen:?}"
460 );
461 assert_eq!(std::fs::read(&content_path).unwrap(), b"second");
462 }
463}