Skip to main content

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}