Skip to main content

slipcase_open/
watch.rs

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