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: ¬ify::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(®istry, &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(®istry, &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(®istry, &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}