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}