Skip to main content

teksilo_settings/
watch.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Live cross-process settings sync: a `notify`-based directory watcher
5//! plus the registry that lets a changed path be dispatched to the
6//! in-memory [`Reloadable`] handle that owns it.
7//!
8//! This is the read-side counterpart to the write-side cross-process
9//! safety documented in `flush.rs` / `reload.rs`: every write in this
10//! crate already merges safely against a peer's concurrent write, but a
11//! process that never looks again will not notice a peer's write until
12//! it happens to touch the same key itself. [`SettingsWatcher`] is what
13//! makes it look again, automatically, the moment a peer's write lands
14//! on disk.
15//!
16//! ## Shape, mirrored from `teksilo-i18n`'s `FtlFileWatcher`
17//!
18//! [`SettingsWatcher`] owns a `notify::RecommendedWatcher` background
19//! thread and a type-erased sink `Arc<dyn Fn(PathBuf) + Send + Sync>`.
20//! Exactly like `FtlFileWatcher`, it watches **directories**, not files:
21//! atomic writers (this crate's own `write_atomic` included) write a
22//! temp file and rename it over the target, which invalidates an
23//! inode-level watch on the file itself. Unlike `FtlFileWatcher` — which
24//! watches a fixed, already-existing set of `.ftl` files and derives
25//! their parents — `SettingsWatcher` watches the settings *directories*
26//! (`AppPaths::config_dir()` / `AppPaths::data_dir()`) directly, because
27//! the set of settings files living there is open-ended and some of
28//! them (e.g. `window_state.toml`) may not exist yet at watch-construction
29//! time.
30//!
31//! The sink receives the changed path (not yet filtered against anything
32//! this process cares about); [`SettingsRegistry::dispatch`] is what
33//! decides whether the path names something registered and, if so,
34//! calls its [`Reloadable::reload_from_disk`]. A path with no registered
35//! owner (a `.lock` sidecar, a `.tmp` write-in-progress, an unrelated
36//! file a peer dropped in the same directory) is a harmless no-op.
37//!
38//! ## The registry
39//!
40//! [`SettingsRegistry`] maps a canonical path to a `Weak<dyn Reloadable>`.
41//! It never holds a strong reference itself: whoever opens a persisted
42//! service (`SettingsBundle::open`, or application code opening its own
43//! ad hoc `SettingsFile<T>` / `PersistedListModel<T>` / `MruList<T>`)
44//! wraps it in an `Rc<dyn Reloadable>`, registers a weak clone via
45//! [`SettingsRegistry::register`], and keeps the returned `Rc` alive for
46//! as long as it wants peer writes to be picked up. When that `Rc` (and
47//! every clone of it) is dropped, the registry's entry can no longer be
48//! upgraded — [`SettingsRegistry::dispatch`] then quietly prunes it and
49//! reports nothing happened. Nothing leaks and nothing is ever called on
50//! a service that no longer exists.
51
52use std::collections::HashMap;
53use std::path::{Path, PathBuf};
54use std::rc::{Rc, Weak};
55use std::sync::Arc;
56
57use notify::{RecommendedWatcher, RecursiveMode, Watcher};
58
59use crate::file::SettingsFileError;
60use crate::reload::Reloadable;
61
62/// Sink type invoked on the notify worker thread whenever a watched
63/// settings directory reports a create/modify event. Implementations
64/// must be thread-safe; `teksilo-app`'s implementation posts the path
65/// through the winit `EventLoopProxy` as `AppEvent::SettingsReload`,
66/// which hops back onto the UI thread where the (single-threaded,
67/// `Rc`-based) [`SettingsRegistry`] actually lives.
68pub type SettingsReloadSink = Arc<dyn Fn(PathBuf) + Send + Sync + 'static>;
69
70/// Active directory watcher over one or more settings directories. One
71/// per `TeksiloAppBuilder::run` invocation (when a settings bundle with
72/// watching enabled is configured).
73///
74/// Owns the `notify::RecommendedWatcher` background thread for its whole
75/// lifetime; dropping the `SettingsWatcher` stops the watcher and cleans
76/// up. Kept alive by the caller for as long as live reload is wanted —
77/// `teksilo-app` stores it on its window-loop handler, exactly like
78/// `teksilo-i18n`'s `FtlFileWatcher`.
79pub struct SettingsWatcher {
80    _inner: RecommendedWatcher,
81}
82
83impl SettingsWatcher {
84    /// Build a watcher over `dirs` (deduplicated by canonical path, so
85    /// passing the same directory twice — e.g. `AppPaths::for_testing`,
86    /// whose `config_dir()` and `data_dir()` are the same tempdir — never
87    /// double-watches or double-fires) and a sink callback.
88    ///
89    /// A directory that does not exist (or can't be canonicalized for
90    /// any other reason) is logged and skipped — not fatal — since a
91    /// freshly-installed app may not have created its data directory yet
92    /// when this is called. As long as at least the config directory
93    /// exists (which `AppPaths` implies by the time `SettingsBundle` has
94    /// successfully opened anything in it), watching still works for the
95    /// files that matter.
96    pub fn new(dirs: Vec<PathBuf>, sink: SettingsReloadSink) -> Result<Self, notify::Error> {
97        let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
98        let mut targets: Vec<PathBuf> = Vec::new();
99        for dir in dirs {
100            let canonical = match dir.canonicalize() {
101                Ok(p) => p,
102                Err(e) => {
103                    eprintln!(
104                        "teksilo-settings: cannot watch `{}` ({e}); live settings reload \
105                         disabled for that directory",
106                        dir.display()
107                    );
108                    continue;
109                }
110            };
111            if seen.insert(canonical.clone()) {
112                targets.push(canonical);
113            }
114        }
115
116        let sink_handle = sink.clone();
117        let mut watcher = notify::recommended_watcher(
118            move |res: Result<notify::Event, notify::Error>| match res {
119                Ok(event) if should_reload(&event.kind) => {
120                    for path in &event.paths {
121                        (sink_handle)(path.clone());
122                    }
123                }
124                Ok(_) => {}
125                Err(e) => {
126                    eprintln!("teksilo-settings: watcher error: {e}");
127                }
128            },
129        )?;
130
131        for target in &targets {
132            watcher.watch(target, RecursiveMode::NonRecursive)?;
133        }
134
135        Ok(Self { _inner: watcher })
136    }
137}
138
139/// Return `true` for event kinds that mean a file's content may have
140/// changed. `notify` fires many events for other operations (access,
141/// metadata-only, permissions) that never need a reload. Mirrors
142/// `teksilo-i18n::file_watcher::should_reload` exactly.
143fn should_reload(kind: &notify::EventKind) -> bool {
144    use notify::EventKind::*;
145    matches!(kind, Modify(_) | Create(_))
146}
147
148/// Best-effort canonical form of `path`, robust to the file itself not
149/// existing yet on disk (unlike [`Path::canonicalize`], which requires
150/// every component — including the leaf — to exist).
151///
152/// Canonicalizes the *parent* directory (which every registered settings
153/// path has, and which is guaranteed to exist before anything is ever
154/// written into it) and rejoins the file name. This is the same key a
155/// [`SettingsWatcher`] event path resolves to, since the watcher is
156/// always constructed over the canonicalized parent directory too — so
157/// a registration made before a file's first write and an event fired
158/// after it exists still land on the same map key.
159fn canonical_settings_path(path: &Path) -> PathBuf {
160    match (path.parent(), path.file_name()) {
161        (Some(parent), Some(name)) => match parent.canonicalize() {
162            Ok(canonical_parent) => canonical_parent.join(name),
163            Err(_) => path.to_path_buf(),
164        },
165        _ => path.to_path_buf(),
166    }
167}
168
169/// Registry mapping a canonical settings path to the live [`Reloadable`]
170/// handle that owns it, so a file-watcher event naming that path can be
171/// dispatched to the right in-memory state.
172///
173/// `Clone` is cheap (an `Rc` bump) — every clone shares the same
174/// underlying map, matching the rest of this crate's handle types.
175/// Holds only [`Weak`] references: see the module docs' "the registry"
176/// section for the full ownership contract.
177#[derive(Clone, Default)]
178pub struct SettingsRegistry {
179    entries: Rc<std::cell::RefCell<HashMap<PathBuf, Weak<dyn Reloadable>>>>,
180}
181
182impl SettingsRegistry {
183    /// A fresh, empty registry.
184    pub fn new() -> Self {
185        Self::default()
186    }
187
188    /// Register `reloadable` under its canonical path and return it back
189    /// unchanged, so a caller can register and retain in one expression:
190    ///
191    /// ```
192    /// use teksilo_settings::{SettingsRegistry, SettingsFile, Migrator, Versioned};
193    /// use serde::{Serialize, Deserialize};
194    /// use std::rc::Rc;
195    ///
196    /// #[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
197    /// struct Prefs { version: u32 }
198    /// impl Versioned for Prefs {
199    ///     const CURRENT_VERSION: u32 = 1;
200    ///     fn version(&self) -> u32 { self.version }
201    ///     fn set_version(&mut self, v: u32) { self.version = v; }
202    /// }
203    ///
204    /// let dir = tempfile::tempdir().unwrap();
205    /// let file: SettingsFile<Prefs> =
206    ///     SettingsFile::load(dir.path().join("prefs.toml"), Migrator::new()).unwrap();
207    ///
208    /// let registry = SettingsRegistry::new();
209    /// // Keep `handle` alive for as long as reload should keep working.
210    /// let handle = registry.register(Rc::new(file.clone()));
211    /// drop(handle); // dropping it deregisters: no leak, no dangling call.
212    /// ```
213    ///
214    /// The caller is responsible for keeping the returned `Rc` alive —
215    /// only a `Weak` is retained internally, by design (see the module
216    /// docs). Registering a second `Reloadable` under the same canonical
217    /// path replaces the first entry.
218    pub fn register(&self, reloadable: Rc<dyn Reloadable>) -> Rc<dyn Reloadable> {
219        let key = canonical_settings_path(reloadable.path());
220        self.entries
221            .borrow_mut()
222            .insert(key, Rc::downgrade(&reloadable));
223        reloadable
224    }
225
226    /// Look up `changed_path`'s registered owner and call
227    /// [`Reloadable::reload_from_disk`] on it.
228    ///
229    /// Returns `Ok(true)` if the owner's in-memory state actually
230    /// changed, `Ok(false)` if nothing needed to change (including: the
231    /// path names nothing registered, or its owner has been dropped —
232    /// in the latter case the dead entry is pruned from the map so it
233    /// doesn't accumulate forever).
234    pub fn dispatch(&self, changed_path: &Path) -> Result<bool, SettingsFileError> {
235        let key = canonical_settings_path(changed_path);
236        let weak = { self.entries.borrow().get(&key).cloned() };
237        let Some(weak) = weak else {
238            return Ok(false);
239        };
240        match weak.upgrade() {
241            Some(reloadable) => reloadable.reload_from_disk(),
242            None => {
243                self.entries.borrow_mut().remove(&key);
244                Ok(false)
245            }
246        }
247    }
248
249    /// The canonical paths currently registered (including entries whose
250    /// owner has since been dropped but not yet pruned by a `dispatch`
251    /// call). Exposed for tests and diagnostics.
252    pub fn registered_paths(&self) -> Vec<PathBuf> {
253        self.entries.borrow().keys().cloned().collect()
254    }
255
256    /// Number of live (upgradeable) entries. Exposed for tests.
257    pub fn live_count(&self) -> usize {
258        self.entries
259            .borrow()
260            .values()
261            .filter(|w| w.upgrade().is_some())
262            .count()
263    }
264}
265
266impl std::fmt::Debug for SettingsRegistry {
267    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268        let entries = self.entries.borrow();
269        f.debug_struct("SettingsRegistry")
270            .field("registered_paths", &entries.len())
271            .field(
272                "live",
273                &entries.values().filter(|w| w.upgrade().is_some()).count(),
274            )
275            .finish()
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::file::SettingsFile;
283    use crate::migration::{Migrator, Versioned};
284    use serde::{Deserialize, Serialize};
285    use std::time::{Duration, Instant};
286    use tempfile::tempdir;
287
288    #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
289    struct Prefs {
290        version: u32,
291        value: String,
292    }
293    impl Versioned for Prefs {
294        const CURRENT_VERSION: u32 = 1;
295        fn version(&self) -> u32 {
296            self.version
297        }
298        fn set_version(&mut self, v: u32) {
299            self.version = v;
300        }
301    }
302
303    /// Poll `condition` for up to `timeout`, sleeping briefly between
304    /// checks. Never a blind fixed sleep: returns as soon as the
305    /// condition is true, and fails loudly (via the caller's assertion)
306    /// if it never becomes true within the (generous) budget.
307    fn poll_until(timeout: Duration, mut condition: impl FnMut() -> bool) -> bool {
308        let deadline = Instant::now() + timeout;
309        loop {
310            if condition() {
311                return true;
312            }
313            if Instant::now() >= deadline {
314                return false;
315            }
316            std::thread::sleep(Duration::from_millis(20));
317        }
318    }
319
320    const GENEROUS_TIMEOUT: Duration = Duration::from_secs(5);
321
322    // -----------------------------------------------------------------
323    // canonical_settings_path
324    // -----------------------------------------------------------------
325
326    #[test]
327    fn canonical_settings_path_resolves_even_when_file_is_missing() {
328        let dir = tempdir().unwrap();
329        let missing = dir.path().join("not-yet-written.toml");
330        let resolved = canonical_settings_path(&missing);
331        // The parent must have been canonicalized even though the leaf
332        // file does not exist.
333        assert_eq!(
334            resolved.parent().unwrap(),
335            dir.path().canonicalize().unwrap()
336        );
337        assert_eq!(resolved.file_name().unwrap(), "not-yet-written.toml");
338    }
339
340    #[test]
341    fn canonical_settings_path_is_stable_across_existence() {
342        let dir = tempdir().unwrap();
343        let path = dir.path().join("general.toml");
344
345        let before = canonical_settings_path(&path);
346        std::fs::write(&path, "version = 1\n").unwrap();
347        let after = canonical_settings_path(&path);
348
349        assert_eq!(
350            before, after,
351            "registering before vs. after creation must key identically"
352        );
353    }
354
355    // -----------------------------------------------------------------
356    // SettingsRegistry
357    // -----------------------------------------------------------------
358
359    #[test]
360    fn dispatch_on_unregistered_path_is_a_harmless_no_op() {
361        let registry = SettingsRegistry::new();
362        let dir = tempdir().unwrap();
363        assert!(!registry.dispatch(&dir.path().join("unknown.toml")).unwrap());
364    }
365
366    #[test]
367    fn register_then_dispatch_reloads_a_peers_write() {
368        let dir = tempdir().unwrap();
369        let path = dir.path().join("prefs.toml");
370
371        let a: SettingsFile<Prefs> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
372        let b: SettingsFile<Prefs> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
373
374        let registry = SettingsRegistry::new();
375        let _handle = registry.register(Rc::new(b.clone()) as Rc<dyn Reloadable>);
376
377        a.mutate(|p| p.value = "peer-write".into()).unwrap();
378
379        assert!(registry.dispatch(&path).unwrap());
380        assert_eq!(b.snapshot().value, "peer-write");
381    }
382
383    #[test]
384    fn dropped_service_is_pruned_and_never_dispatched_to() {
385        let dir = tempdir().unwrap();
386        let path = dir.path().join("prefs.toml");
387
388        let a: SettingsFile<Prefs> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
389        let b: SettingsFile<Prefs> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
390
391        let registry = SettingsRegistry::new();
392        let handle = registry.register(Rc::new(b.clone()) as Rc<dyn Reloadable>);
393        assert_eq!(registry.live_count(), 1);
394
395        drop(handle);
396        // `b` itself is still alive (it's a separate `SettingsFile` clone
397        // handle sharing the same on-disk file), but the *registration*
398        // Rc was the only strong reference the registry's Weak could
399        // upgrade through — dropping it must deregister.
400        assert_eq!(registry.live_count(), 0);
401
402        a.mutate(|p| p.value = "peer-write-after-drop".into())
403            .unwrap();
404
405        // No owner to dispatch to any more: reports nothing happened,
406        // and prunes the dead entry.
407        assert!(!registry.dispatch(&path).unwrap());
408        assert!(registry.registered_paths().is_empty());
409        // `b`'s own in-memory value is untouched — nothing was called on it.
410        assert_eq!(b.snapshot().value, "");
411    }
412
413    #[test]
414    fn registering_under_the_same_path_replaces_the_previous_owner() {
415        let dir = tempdir().unwrap();
416        let path = dir.path().join("prefs.toml");
417
418        let first: SettingsFile<Prefs> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
419        let second: SettingsFile<Prefs> =
420            SettingsFile::load(path.clone(), Migrator::new()).unwrap();
421
422        let registry = SettingsRegistry::new();
423        let _first_handle = registry.register(Rc::new(first.clone()) as Rc<dyn Reloadable>);
424        let _second_handle = registry.register(Rc::new(second.clone()) as Rc<dyn Reloadable>);
425
426        assert_eq!(registry.registered_paths().len(), 1);
427
428        let writer: SettingsFile<Prefs> =
429            SettingsFile::load(path.clone(), Migrator::new()).unwrap();
430        writer.mutate(|p| p.value = "via-second".into()).unwrap();
431
432        assert!(registry.dispatch(&path).unwrap());
433        assert_eq!(second.snapshot().value, "via-second");
434        // `first` was replaced in the registry and never touched.
435        assert_eq!(first.snapshot().value, "");
436    }
437
438    // -----------------------------------------------------------------
439    // SettingsWatcher — end-to-end, real notify + real filesystem.
440    // -----------------------------------------------------------------
441
442    #[test]
443    fn construction_over_a_missing_directory_does_not_error() {
444        let sink: SettingsReloadSink = Arc::new(|_path| {});
445        let watcher = SettingsWatcher::new(
446            vec![PathBuf::from("/definitely/does/not/exist/anywhere")],
447            sink,
448        );
449        assert!(watcher.is_ok());
450    }
451
452    #[test]
453    fn construction_dedupes_identical_directories() {
454        // `AppPaths::for_testing` routes config_dir and data_dir to the
455        // same tempdir; watching it twice must not error (and must not
456        // register the OS-level watch twice).
457        let dir = tempdir().unwrap();
458        let sink: SettingsReloadSink = Arc::new(|_path| {});
459        let watcher = SettingsWatcher::new(
460            vec![dir.path().to_path_buf(), dir.path().to_path_buf()],
461            sink,
462        );
463        assert!(watcher.is_ok());
464    }
465
466    /// `SettingsRegistry` is `Rc`-based (single-threaded) by design — it
467    /// is only ever touched from the UI thread in production, reached
468    /// via an `AppEvent` posted through the winit proxy (see
469    /// `teksilo-app`'s wiring). A `SettingsWatcher`'s sink, in contrast,
470    /// runs on the notify background thread and must be `Send + Sync`.
471    /// These tests reproduce that exact split: the sink only pushes the
472    /// changed path onto a thread-safe queue; the *test* thread (which
473    /// is also where every `SettingsFile` / `SettingsRegistry` handle
474    /// below was constructed) drains it and calls `dispatch` itself —
475    /// exactly mirroring the real cross-thread handoff.
476    type PathQueue = Arc<std::sync::Mutex<std::collections::VecDeque<PathBuf>>>;
477
478    fn queueing_sink(queue: PathQueue) -> SettingsReloadSink {
479        Arc::new(move |path| {
480            queue.lock().unwrap().push_back(path);
481        })
482    }
483
484    /// Drain every path currently queued and dispatch each through
485    /// `registry` (on the calling thread), returning how many produced
486    /// an *effective* reload (`Ok(true)`).
487    fn drain_and_dispatch(registry: &SettingsRegistry, queue: &PathQueue) -> usize {
488        let paths: Vec<PathBuf> = queue.lock().unwrap().drain(..).collect();
489        paths
490            .into_iter()
491            .filter(|p| matches!(registry.dispatch(p), Ok(true)))
492            .count()
493    }
494
495    /// An external write (a second handle, standing in for a peer
496    /// process) to a watched, registered file drives exactly one
497    /// *effective* reload — i.e. the registered `Reloadable`'s
498    /// in-memory state flips exactly once, no matter how many raw
499    /// filesystem events the OS coalesces the write into. This is the
500    /// self-write-suppression / content-backstop contract in
501    /// `reload_from_disk` doing its job on top of a possibly-noisy
502    /// stream of notify events — exactly the property a watcher must
503    /// preserve.
504    #[test]
505    fn external_write_triggers_exactly_one_effective_reload() {
506        let dir = tempdir().unwrap();
507        let path = dir.path().join("prefs.toml");
508
509        // Create the file up front so `dir` already contains something
510        // watchable and the peer's first write is a real Modify.
511        let peer: SettingsFile<Prefs> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
512        let mine: SettingsFile<Prefs> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
513
514        let registry = SettingsRegistry::new();
515        let _handle = registry.register(Rc::new(mine.clone()) as Rc<dyn Reloadable>);
516
517        let queue: PathQueue = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new()));
518        let _watcher =
519            SettingsWatcher::new(vec![dir.path().to_path_buf()], queueing_sink(queue.clone()))
520                .unwrap();
521
522        peer.mutate(|p| p.value = "external".into()).unwrap();
523
524        let mut effective_reloads = 0usize;
525        assert!(
526            poll_until(GENEROUS_TIMEOUT, || {
527                effective_reloads += drain_and_dispatch(&registry, &queue);
528                effective_reloads >= 1
529            }),
530            "expected the external write to be picked up within the timeout"
531        );
532
533        // Give any duplicate/coalesced OS events a further bounded window
534        // to arrive and prove they don't cause a second *effective*
535        // reload (the stamp/content backstop must absorb them).
536        let deadline = Instant::now() + Duration::from_millis(300);
537        while Instant::now() < deadline {
538            effective_reloads += drain_and_dispatch(&registry, &queue);
539            std::thread::sleep(Duration::from_millis(20));
540        }
541        assert_eq!(
542            effective_reloads, 1,
543            "exactly one effective reload, however many raw fs events fired"
544        );
545        assert_eq!(mine.snapshot().value, "external");
546    }
547
548    /// Our own write must never be treated as a peer's: dispatching the
549    /// path right after our own `mutate` must report `Ok(false)` and
550    /// touch nothing, because `reload_from_disk`'s stamp check
551    /// recognizes it as already-current.
552    #[test]
553    fn our_own_write_triggers_no_effective_reload() {
554        let dir = tempdir().unwrap();
555        let path = dir.path().join("prefs.toml");
556
557        let mine: SettingsFile<Prefs> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
558        let registry = SettingsRegistry::new();
559        let _handle = registry.register(Rc::new(mine.clone()) as Rc<dyn Reloadable>);
560
561        mine.mutate(|p| p.value = "mine".into()).unwrap();
562
563        // Simulate the watcher noticing our own write (it would: the OS
564        // can't tell who wrote it) and dispatching it straight through.
565        assert!(!registry.dispatch(&path).unwrap());
566        assert_eq!(
567            mine.snapshot().value,
568            "mine",
569            "our own value must be untouched"
570        );
571    }
572
573    /// A dropped service's registration must never fire, even though its
574    /// underlying file keeps being written to by a peer. Proven via a
575    /// live sentinel registered on a *different* file in the same
576    /// watched directory: once the sentinel's write is observed, the
577    /// dropped service's counter is asserted to still be zero — bounding
578    /// the wait deterministically instead of a blind sleep.
579    #[test]
580    fn dropped_service_is_never_called_by_a_live_watcher() {
581        let dir = tempdir().unwrap();
582        let dropped_path = dir.path().join("dropped.toml");
583        let sentinel_path = dir.path().join("sentinel.toml");
584
585        let dropped_peer: SettingsFile<Prefs> =
586            SettingsFile::load(dropped_path.clone(), Migrator::new()).unwrap();
587        let dropped_mine: SettingsFile<Prefs> =
588            SettingsFile::load(dropped_path.clone(), Migrator::new()).unwrap();
589        let sentinel_peer: SettingsFile<Prefs> =
590            SettingsFile::load(sentinel_path.clone(), Migrator::new()).unwrap();
591        let sentinel_mine: SettingsFile<Prefs> =
592            SettingsFile::load(sentinel_path.clone(), Migrator::new()).unwrap();
593
594        let registry = SettingsRegistry::new();
595        let dropped_handle = registry.register(Rc::new(dropped_mine.clone()) as Rc<dyn Reloadable>);
596        let _sentinel_handle =
597            registry.register(Rc::new(sentinel_mine.clone()) as Rc<dyn Reloadable>);
598
599        // Drop the "dropped" service's registration handle before any
600        // write happens.
601        drop(dropped_handle);
602        assert_eq!(registry.live_count(), 1);
603
604        let queue: PathQueue = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new()));
605        let _watcher =
606            SettingsWatcher::new(vec![dir.path().to_path_buf()], queueing_sink(queue.clone()))
607                .unwrap();
608
609        dropped_peer
610            .mutate(|p| p.value = "should-never-land".into())
611            .unwrap();
612        sentinel_peer
613            .mutate(|p| p.value = "sentinel-fired".into())
614            .unwrap();
615
616        let dropped_key = canonical_settings_path(&dropped_path);
617        let sentinel_key = canonical_settings_path(&sentinel_path);
618        let mut dropped_reloads = 0usize;
619        let mut sentinel_reloads = 0usize;
620
621        // Manual poll loop (not `poll_until`, which only tracks a single
622        // `bool` condition) so both counters can be accumulated on every
623        // iteration without a nested-closure double-borrow.
624        let deadline = Instant::now() + GENEROUS_TIMEOUT;
625        loop {
626            let paths: Vec<PathBuf> = queue.lock().unwrap().drain(..).collect();
627            for p in paths {
628                let key = canonical_settings_path(&p);
629                if let Ok(true) = registry.dispatch(&p) {
630                    if key == dropped_key {
631                        dropped_reloads += 1;
632                    } else if key == sentinel_key {
633                        sentinel_reloads += 1;
634                    }
635                }
636            }
637            if sentinel_reloads >= 1 || Instant::now() >= deadline {
638                break;
639            }
640            std::thread::sleep(Duration::from_millis(20));
641        }
642
643        assert!(
644            sentinel_reloads >= 1,
645            "sentinel write should have been observed within the timeout"
646        );
647        assert_eq!(
648            dropped_reloads, 0,
649            "a dropped service's registration must never be dispatched to"
650        );
651        assert_eq!(
652            dropped_mine.snapshot().value,
653            "",
654            "the dropped service's in-memory value must be untouched"
655        );
656    }
657
658    /// The rename dance: deleting the underlying file and recreating it
659    /// must not break the watch (which targets the parent directory, not
660    /// the file's inode) — the recreation is picked up like any other
661    /// write.
662    #[test]
663    fn survives_file_deletion_and_recreation() {
664        let dir = tempdir().unwrap();
665        let path = dir.path().join("prefs.toml");
666
667        // File must exist up front for the initial `load` to establish a
668        // stamp baseline consistent with the rest of the suite.
669        std::fs::write(&path, "version = 1\nvalue = \"\"\n").unwrap();
670        let mine: SettingsFile<Prefs> = SettingsFile::load(path.clone(), Migrator::new()).unwrap();
671
672        let registry = SettingsRegistry::new();
673        let _handle = registry.register(Rc::new(mine.clone()) as Rc<dyn Reloadable>);
674
675        let queue: PathQueue = Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new()));
676        let _watcher =
677            SettingsWatcher::new(vec![dir.path().to_path_buf()], queueing_sink(queue.clone()))
678                .unwrap();
679
680        // Delete the file entirely (no peer handle involved — a plain
681        // `remove_file`, closer to what an external "reset settings"
682        // tool would do), then recreate it via a fresh locked write,
683        // exactly like `write_atomic`'s temp-file-then-rename.
684        std::fs::remove_file(&path).unwrap();
685
686        let recreated: SettingsFile<Prefs> =
687            SettingsFile::load(path.clone(), Migrator::new()).unwrap();
688        recreated
689            .mutate(|p| p.value = "recreated-after-delete".into())
690            .unwrap();
691
692        let mut effective_reloads = 0usize;
693        assert!(
694            poll_until(GENEROUS_TIMEOUT, || {
695                effective_reloads += drain_and_dispatch(&registry, &queue);
696                effective_reloads >= 1
697            }),
698            "the recreated file's write should still be observed after the watched \
699             file was deleted and recreated"
700        );
701        assert_eq!(mine.snapshot().value, "recreated-after-delete");
702    }
703}