Skip to main content

dynamic_config/
watch.rs

1//! The filesystem watcher behind hot reload.
2
3use std::any::TypeId;
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use std::sync::mpsc;
7use std::sync::Mutex;
8use std::thread;
9use std::time::Duration;
10
11use notify::{Event, EventKind, RecursiveMode, Watcher};
12
13use crate::discovery;
14use crate::error::Error;
15#[cfg(not(feature = "tracing"))]
16use crate::log::info;
17use crate::log::warning;
18use crate::source::LoadSpec;
19
20/// What a watcher looks at, owned.
21///
22/// The watch used to borrow a `LoadSpec<'static>`, which chained every
23/// watcher to statics only the attribute can produce. Owning the three
24/// facts the watch actually uses — the explicit file paths, the discovery
25/// name, the searched directories — frees the builder (or anything else)
26/// to start one from runtime data.
27#[derive(Debug, Clone)]
28pub struct Watched {
29    files: Vec<PathBuf>,
30    search_name: Option<String>,
31    search_directories: Vec<PathBuf>,
32}
33
34impl Watched {
35    /// Captures what a watcher needs from `spec`, with any lifetime.
36    ///
37    /// The searched directories are resolved here, once — the same moment
38    /// the directory watches are registered, so the two cannot disagree.
39    #[must_use]
40    pub fn from_spec(spec: &LoadSpec<'_>) -> Self {
41        Self {
42            files: spec
43                .sources
44                .iter()
45                .filter_map(|source| source.path())
46                .map(PathBuf::from)
47                .collect(),
48            search_name: spec.search.as_ref().map(|search| search.name.to_owned()),
49            search_directories: spec
50                .search
51                .as_ref()
52                .map(|search| discovery::search_directories(search))
53                .unwrap_or_default(),
54        }
55    }
56}
57
58/// Pause after the debounce window, before the files are read back.
59///
60/// An atomic save writes a temporary file and renames it into place. The rename
61/// can be observed a hair before the new inode is visible, so a short grace
62/// period avoids reading a file that is about to be replaced.
63const ATOMIC_SAVE_GRACE: Duration = Duration::from_millis(25);
64
65/// How to detect changes.
66///
67/// The native backend is right almost everywhere and wrong in one important
68/// place: inotify and its equivalents do not fire on many network and overlay
69/// filesystems — NFS, some Docker bind mounts, some CI runners. The failure is
70/// silent, because the watch registers successfully and simply never delivers
71/// anything, so there is nothing to detect and fall back from. It has to be
72/// chosen deliberately.
73#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum WatchMode {
76    /// The platform's notification backend. Efficient, and the default.
77    #[default]
78    Native,
79    /// Re-stat the files on an interval. Works anywhere, at the cost of the
80    /// interval's worth of latency and a periodic wake-up.
81    Poll {
82        /// How often to look.
83        interval: Duration,
84    },
85}
86
87/// Types that already have a watcher, keyed by [`TypeId`] — the one identity
88/// that survives generics. The display name is kept only for messages: keyed
89/// by name, `Db<Postgres>` and `Db<Mysql>` both stringify to `"Db"`, and the
90/// second `start_watch()` silently watched nothing.
91static STARTED: Mutex<BTreeMap<TypeId, &'static str>> = Mutex::new(BTreeMap::new());
92
93/// Keeps a watcher alive. Dropping it stops watching.
94///
95/// The handle owns the notification backend, and the background thread owns
96/// only the receiving end. Dropping the handle closes the channel, which is
97/// what ends the thread — no flag to poll, no wake-up latency.
98///
99/// A server usually wants the watcher to outlive everything, which is what
100/// [`detach`](Self::detach) is for. Anything with a lifecycle — a test, a
101/// library, a subcommand — should hold the handle instead, so watching stops
102/// when the thing being configured goes away.
103#[must_use = "dropping the handle stops the watcher; bind it, or call `.detach()` \
104              to watch for the rest of the process"]
105pub struct WatchHandle {
106    key: TypeId,
107    name: &'static str,
108    /// `None` only while `detach` is dismantling the handle.
109    watcher: Option<Backend>,
110}
111
112/// The two backends, kept as one owner so the handle is a single type.
113enum Backend {
114    Native(notify::RecommendedWatcher),
115    Poll(notify::PollWatcher),
116}
117
118impl WatchHandle {
119    /// Watches for the remainder of the process.
120    ///
121    /// Leaks the backend on purpose: a watcher that must never stop has no
122    /// owner to hold it, and pretending otherwise is how the handle ends up
123    /// dropped at the end of `main`'s first statement.
124    pub fn detach(mut self) {
125        if let Some(watcher) = self.watcher.take() {
126            std::mem::forget(watcher);
127        }
128
129        // The registration stays, so a later `spawn` still reports
130        // `AlreadyExists` rather than starting a second watcher.
131        std::mem::forget(self);
132    }
133
134    /// Stops watching. The same as dropping it, spelled out.
135    pub fn stop(self) {}
136
137    /// The type name this watcher was started for.
138    #[must_use]
139    pub fn name(&self) -> &'static str {
140        self.name
141    }
142}
143
144impl Drop for WatchHandle {
145    fn drop(&mut self) {
146        // `None` only mid-`detach`, which forgets the handle before this
147        // could run — but belt and braces costs one branch.
148        let Some(watcher) = self.watcher.take() else {
149            return;
150        };
151
152        // Dropping the backend closes the channel and ends the thread. Freeing
153        // the registration lets a later `spawn` start a fresh one — which is
154        // what makes this usable from tests.
155        drop(watcher);
156
157        // Recovered from poisoning rather than skipped: skipping would leak
158        // the registration forever, and the map has no invariant a panic
159        // could break — the same policy every other lock in the crate follows.
160        STARTED
161            .lock()
162            .unwrap_or_else(std::sync::PoisonError::into_inner)
163            .remove(&self.key);
164    }
165}
166
167impl std::fmt::Debug for WatchHandle {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.debug_struct("WatchHandle")
170            .field("name", &self.name)
171            // The backend is a notify watcher, which has no rendering worth
172            // printing and would drown the one field that matters.
173            .finish_non_exhaustive()
174    }
175}
176
177/// Starts a background thread that runs `reload` whenever one of `files` changes.
178///
179/// Calling this twice for the same type is an error (`AlreadyExists`): a
180/// second handle could only mislead, and the first watcher keeps running.
181///
182/// `reload` is expected to swap in a new snapshot. Returning `Some(summary)`
183/// replaces the generic "reloaded" line with something more specific — which is
184/// how `diff` reports the keys that moved without logging twice.
185///
186/// Its error is reported and discarded — an invalid or half-written file must
187/// degrade to "no change", never to a crash, because the previous snapshot is
188/// still perfectly good.
189///
190/// The watch is registered *before* this function returns, so an edit that
191/// lands immediately afterwards cannot slip through the gap. Registering it on
192/// the background thread instead would leave a window — short, but reliably hit
193/// by anything that writes configuration during startup.
194///
195/// # Errors
196///
197/// If the notification backend cannot be created, if none of the directories
198/// holding `files` can be watched, or if the thread cannot be spawned. A
199/// directory that fails while others succeed is reported and skipped.
200///
201pub fn spawn(
202    key: TypeId,
203    name: &'static str,
204    watched: Watched,
205    debounce: Duration,
206    reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
207) -> std::io::Result<WatchHandle> {
208    spawn_with(key, name, watched, debounce, WatchMode::default(), reload)
209}
210
211/// [`spawn`], with the detection strategy chosen explicitly.
212///
213/// # Errors
214///
215/// As [`spawn`].
216///
217pub fn spawn_with(
218    key: TypeId,
219    name: &'static str,
220    watched: Watched,
221    debounce: Duration,
222    mode: WatchMode,
223    reload: impl Fn() -> Result<Option<String>, Error> + Send + 'static,
224) -> std::io::Result<WatchHandle> {
225    // An error, not a quiet no-op handle. The old behaviour returned
226    // `Ok(handle-that-owns-nothing)`, which read as "I started watching" and
227    // was undetectable at runtime — the worst kind of success.
228    if STARTED
229        .lock()
230        .unwrap_or_else(std::sync::PoisonError::into_inner)
231        .insert(key, name)
232        .is_some()
233    {
234        return Err(std::io::Error::new(
235            std::io::ErrorKind::AlreadyExists,
236            format!(
237                "`{name}` is already being watched; hold on to the handle the \
238                 first `start_watch()` returned, or drop it before starting \
239                 another"
240            ),
241        ));
242    }
243
244    // The insertion above is what makes two concurrent `spawn` calls mutually
245    // exclusive, so it has to come first — and therefore a failure below has
246    // to undo it. Without the rollback, every later `start_watch()` for this
247    // type would find the name taken and return a success handle that owns
248    // nothing and watches nothing, silently.
249    let registered = Registered { key, armed: true };
250
251    let (sender, receiver) = mpsc::channel::<notify::Result<Event>>();
252
253    let mut backend = match mode {
254        WatchMode::Native => Backend::Native(notify::recommended_watcher(sender).map_err(to_io)?),
255        WatchMode::Poll { interval } => Backend::Poll(
256            notify::PollWatcher::new(
257                sender,
258                notify::Config::default().with_poll_interval(interval),
259            )
260            .map_err(to_io)?,
261        ),
262    };
263
264    match &mut backend {
265        Backend::Native(watcher) => watch_directories(name, watcher, &watched)?,
266        Backend::Poll(watcher) => watch_directories(name, watcher, &watched)?,
267    }
268
269    thread::Builder::new()
270        .name(format!("config-watch-{name}"))
271        .spawn(move || run(name, &watched, debounce, reload, &receiver))?;
272
273    // Everything that could fail has succeeded; from here the *handle* owns
274    // the registration and frees it on drop.
275    registered.defuse();
276
277    Ok(WatchHandle {
278        key,
279        name,
280        watcher: Some(backend),
281    })
282}
283
284/// Rolls the name registration back unless the spawn completed.
285///
286/// Every `?` between the insertion and the end of `spawn_with` — the backend,
287/// the directory watches, the thread — runs through this on the way out.
288struct Registered {
289    key: TypeId,
290    armed: bool,
291}
292
293impl Registered {
294    /// The spawn completed; the registration now belongs to the handle.
295    fn defuse(mut self) {
296        self.armed = false;
297    }
298}
299
300impl Drop for Registered {
301    fn drop(&mut self) {
302        if self.armed {
303            STARTED
304                .lock()
305                .unwrap_or_else(std::sync::PoisonError::into_inner)
306                .remove(&self.key);
307        }
308    }
309}
310
311fn to_io(error: notify::Error) -> std::io::Error {
312    std::io::Error::new(std::io::ErrorKind::Other, error)
313}
314
315fn run(
316    name: &'static str,
317    watched: &Watched,
318    debounce: Duration,
319    reload: impl Fn() -> Result<Option<String>, Error>,
320    receiver: &mpsc::Receiver<notify::Result<Event>>,
321) {
322    loop {
323        match collect_relevant(receiver, name, debounce, watched) {
324            Collected::Dirty => {}
325            Collected::Disconnected => {
326                // The watcher was dropped, so no further events can arrive.
327                return;
328            }
329        }
330
331        thread::sleep(ATOMIC_SAVE_GRACE);
332
333        // Under `tracing`, the reload is a span with its outcome and
334        // duration as fields — enough to alert on "has not reloaded
335        // cleanly in an hour" without parsing message strings. Without it,
336        // the messages below carry the duration and stderr carries the
337        // messages.
338        #[cfg(feature = "tracing")]
339        let _span = ::tracing::info_span!(target: "dynamic_config", "config_reload", config = name)
340            .entered();
341
342        let started = std::time::Instant::now();
343        let outcome = reload();
344        let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
345
346        // Under `tracing`, the outcome and duration are structured *fields*
347        // — the alert the docs promise must not require parsing message
348        // strings. Without it, the stderr lines carry both in text.
349        #[cfg(feature = "tracing")]
350        match &outcome {
351            Ok(summary) => ::tracing::info!(
352                target: "dynamic_config",
353                config = name,
354                outcome = "reloaded",
355                duration_ms,
356                summary = summary.as_deref().unwrap_or(""),
357                "{name}: reloaded in {duration_ms}ms"
358            ),
359            Err(error) => ::tracing::warn!(
360                target: "dynamic_config",
361                config = name,
362                outcome = "failed",
363                duration_ms,
364                error = %error,
365                "{name}: reload failed in {duration_ms}ms, keeping the previous snapshot"
366            ),
367        }
368
369        #[cfg(not(feature = "tracing"))]
370        match outcome {
371            Ok(Some(summary)) => info!("{name}: reloaded in {duration_ms}ms, {summary}"),
372            Ok(None) => info!("{name}: reloaded in {duration_ms}ms"),
373            Err(error) => warning!(
374                "{name}: reload failed after {duration_ms}ms, keeping the previous snapshot: \
375                 {error}"
376            ),
377        }
378    }
379}
380
381/// What one round of event collection concluded.
382enum Collected {
383    /// A configured file changed; reload.
384    Dirty,
385    /// The channel closed; the watch is over.
386    Disconnected,
387}
388
389/// Watches the *directories* holding the files, not the files themselves.
390///
391/// Editors and `mv`-based atomic saves replace the inode, which silently
392/// detaches a file-level watch. Watching the parent directory survives that —
393/// and is also what makes a Kubernetes ConfigMap update, delivered as a `..data`
394/// symlink swap, visible at all.
395///
396/// Fails when nothing could be watched, rather than parking a thread on a
397/// channel that will never produce an event.
398fn watch_directories(
399    name: &'static str,
400    watcher: &mut impl Watcher,
401    watched: &Watched,
402) -> std::io::Result<()> {
403    let mut directories = Vec::<PathBuf>::new();
404
405    {
406        let mut push = |directory: PathBuf| {
407            if !directories.contains(&directory) {
408                directories.push(directory);
409            }
410        };
411
412        for file in &watched.files {
413            push(
414                file.parent()
415                    .filter(|parent| !parent.as_os_str().is_empty())
416                    .unwrap_or_else(|| Path::new("."))
417                    .to_path_buf(),
418            );
419        }
420
421        // Every searched directory, whether or not it holds a file today: a
422        // config file appearing later is exactly the event worth catching.
423        for directory in &watched.search_directories {
424            push(directory.clone());
425        }
426    }
427
428    let mut watched = 0usize;
429    let mut last_error = None;
430
431    for directory in &directories {
432        match watcher.watch(directory, RecursiveMode::NonRecursive) {
433            Ok(()) => watched += 1,
434            Err(error) => {
435                warning!("{name}: could not watch {}: {error}", directory.display());
436                last_error = Some(error);
437            }
438        }
439    }
440
441    if watched == 0 {
442        return Err(last_error.map_or_else(
443            || {
444                std::io::Error::new(
445                    std::io::ErrorKind::NotFound,
446                    format!("{name}: no configuration file to watch"),
447                )
448            },
449            to_io,
450        ));
451    }
452
453    Ok(())
454}
455
456/// Blocks until a *relevant* event arrives, then debounces — with a ceiling.
457///
458/// Three deliberate properties, each the fix for a shipped mistake:
459///
460/// - **Relevance is decided per event, before anything else.** The old code
461///   batched first and filtered after, so a chatty neighbour in the config
462///   directory — a log file, a state file — both filled a growing `Vec` and
463///   kept pushing the quiet-period out. An irrelevant event now costs a
464///   comparison and is gone.
465/// - **No batch at all.** One dirty flag: whether a configured file changed
466///   is one bit, and a bit cannot grow.
467/// - **`max_wait` bounds the debounce.** The quiet-period restarts on every
468///   relevant event, which is the point of debouncing — but under a
469///   sustained storm of writes it used to restart forever, and the reload
470///   starved. From the first relevant event, at most `4 × debounce` passes
471///   before the reload happens regardless.
472fn collect_relevant(
473    receiver: &mpsc::Receiver<notify::Result<Event>>,
474    name: &'static str,
475    debounce: Duration,
476    watched: &Watched,
477) -> Collected {
478    // Phase 1: sleep until something we care about happens.
479    loop {
480        match receiver.recv() {
481            Ok(Ok(event)) if is_relevant(&event, watched) => break,
482            Ok(Ok(_)) => {}
483            Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
484            Err(mpsc::RecvError) => return Collected::Disconnected,
485        }
486    }
487
488    // Phase 2: wait out the flurry an editor save produces, but not forever.
489    let deadline = std::time::Instant::now() + debounce.saturating_mul(4);
490    let mut quiet_until = std::time::Instant::now() + debounce;
491
492    loop {
493        let now = std::time::Instant::now();
494        // The nearer of "one debounce of quiet elapsed" and the hard ceiling.
495        let target = quiet_until.min(deadline);
496
497        if now >= target {
498            return Collected::Dirty;
499        }
500
501        match receiver.recv_timeout(target - now) {
502            // A relevant event restarts the quiet period (up to the deadline);
503            // an irrelevant one merely waits out the remainder — a neighbour's
504            // churn must not delay our reload.
505            Ok(Ok(event)) if is_relevant(&event, watched) => {
506                quiet_until = std::time::Instant::now() + debounce;
507            }
508            Ok(Ok(_)) => {}
509            Ok(Err(error)) => warning!("{name}: watcher error: {error}"),
510            Err(mpsc::RecvTimeoutError::Timeout) => return Collected::Dirty,
511            Err(mpsc::RecvTimeoutError::Disconnected) => return Collected::Disconnected,
512        }
513    }
514}
515
516/// Whether one event is about one of our files.
517///
518/// The whole directory is watched, so most events are about something else.
519/// Paths are compared in both directions because event paths are absolute while
520/// configured paths are usually relative to the working directory; a rare false
521/// positive costs one redundant reload, which is harmless.
522fn is_relevant(event: &Event, watched: &Watched) -> bool {
523    matches!(
524        event.kind,
525        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
526    ) && event.paths.iter().any(|changed| is_ours(changed, watched))
527}
528
529fn is_ours(changed: &Path, watched: &Watched) -> bool {
530    let explicit = watched.files.iter().any(|configured| {
531        changed == configured || changed.ends_with(configured) || configured.ends_with(changed)
532    });
533
534    if explicit {
535        return true;
536    }
537
538    // Only the searched directories are watched, so matching on the file name
539    // alone is enough — and it catches a `config.toml` that did not exist when
540    // the watcher started.
541    if watched
542        .search_name
543        .as_deref()
544        .is_some_and(|name| discovery::is_candidate(changed, name))
545    {
546        return true;
547    }
548
549    is_mount_marker(changed, watched)
550}
551
552/// Whether `changed` is the bookkeeping entry of an atomically remounted
553/// directory holding one of our files.
554///
555/// Kubernetes updates a ConfigMap by writing a new timestamped directory and
556/// swinging a `..data` symlink at it. The configuration file's own path never
557/// receives an event — only `..data` and `..2026_08_09_12_00_00` do — so
558/// matching on the file alone sees a ConfigMap update as silence. Entries
559/// beginning with `..` are the kubelet's convention and effectively nothing
560/// else's, which keeps this from firing on ordinary files.
561fn is_mount_marker(changed: &Path, watched: &Watched) -> bool {
562    let is_marker = changed
563        .file_name()
564        .and_then(|name| name.to_str())
565        .is_some_and(|name| name.starts_with(".."));
566
567    if !is_marker {
568        return false;
569    }
570
571    let Some(directory) = changed.parent() else {
572        return false;
573    };
574
575    let mut parents = watched.files.iter().filter_map(|file| file.parent());
576
577    if parents.any(|parent| directory.ends_with(parent) || parent.ends_with(directory)) {
578        return true;
579    }
580
581    watched
582        .search_directories
583        .iter()
584        .any(|parent| directory.ends_with(parent) || parent.ends_with(directory))
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use notify::event::{CreateKind, ModifyKind};
591
592    /// A spec that names one file explicitly and searches nowhere.
593    fn explicit_spec() -> LoadSpec<'static> {
594        static SOURCES: &[crate::Source<'static>] =
595            &[crate::Source::file("config.toml", crate::Format::Toml)];
596
597        LoadSpec::new("app", SOURCES)
598    }
599
600    fn event(kind: EventKind, path: &str) -> Event {
601        Event {
602            kind,
603            paths: vec![PathBuf::from(path)],
604            attrs: Default::default(),
605        }
606    }
607
608    #[test]
609    fn an_absolute_event_path_matches_a_relative_configured_path() {
610        let probe = event(EventKind::Modify(ModifyKind::Any), "/srv/app/config.toml");
611
612        assert!(is_relevant(&probe, &Watched::from_spec(&explicit_spec())));
613    }
614
615    #[test]
616    fn a_discovered_name_matches_even_though_no_file_was_listed() {
617        let paths: &'static [&'static str] = &["/srv/app"];
618        let spec = LoadSpec::new("db", &[]).with_search("config", paths);
619        let watched = Watched::from_spec(&spec);
620
621        let probe = event(EventKind::Create(CreateKind::File), "/srv/app/config.toml");
622        assert!(is_relevant(&probe, &watched));
623
624        let probe = event(EventKind::Create(CreateKind::File), "/srv/app/other.toml");
625        assert!(!is_relevant(&probe, &watched));
626    }
627
628    #[test]
629    fn an_unrelated_file_in_the_same_directory_is_ignored() {
630        let probe = event(EventKind::Modify(ModifyKind::Any), "/srv/app/notes.txt");
631
632        assert!(!is_relevant(&probe, &Watched::from_spec(&explicit_spec())));
633    }
634
635    #[test]
636    fn access_events_do_not_trigger_a_reload() {
637        let probe = event(
638            EventKind::Access(notify::event::AccessKind::Read),
639            "/srv/app/config.toml",
640        );
641
642        assert!(!is_relevant(&probe, &Watched::from_spec(&explicit_spec())));
643    }
644
645    #[test]
646    fn a_duplicate_spawn_is_an_error_and_frees_nothing() {
647        struct DuplicateMarker;
648
649        let spec = explicit_spec();
650        let key = TypeId::of::<DuplicateMarker>();
651
652        let first = spawn(
653            key,
654            "DuplicateTest",
655            Watched::from_spec(&spec),
656            Duration::from_millis(10),
657            || Ok(None),
658        )
659        .expect("the first spawn should start a watcher");
660
661        // The second is refused loudly — the old inert-handle return was a
662        // success nobody could distinguish from the real thing.
663        let spec = explicit_spec();
664        let error = spawn(
665            key,
666            "DuplicateTest",
667            Watched::from_spec(&spec),
668            Duration::from_millis(10),
669            || Ok(None),
670        )
671        .expect_err("a second watcher for the same type must be refused");
672
673        assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
674        assert!(error.to_string().contains("DuplicateTest"), "{error}");
675        assert!(
676            STARTED.lock().unwrap().contains_key(&key),
677            "the refusal must not free the first watcher's registration"
678        );
679
680        drop(first);
681        assert!(
682            !STARTED.lock().unwrap().contains_key(&key),
683            "dropping the real handle frees the registration"
684        );
685
686        // And a fresh spawn works again — the refusal did not wedge the slot.
687        let spec = explicit_spec();
688        let again = spawn(
689            key,
690            "DuplicateTest",
691            Watched::from_spec(&spec),
692            Duration::from_millis(10),
693            || Ok(None),
694        )
695        .expect("after the drop, watching can restart");
696        drop(again);
697    }
698
699    /// A failed spawn must free its name, or every retry afterwards returns a
700    /// success handle that owns nothing and watches nothing — silently, which
701    /// is the exact path a program hits when its config directory does not
702    /// exist yet at startup.
703    #[test]
704    fn a_failed_spawn_frees_its_registration_for_a_retry() {
705        struct FailedSpawnMarker;
706
707        let key = TypeId::of::<FailedSpawnMarker>();
708
709        static BAD: [crate::Source<'static>; 1] = [crate::Source::file(
710            "/nonexistent-dynamic-config-test-dir/config.toml",
711            crate::Format::Toml,
712        )];
713        let bad = LoadSpec::new("db", &BAD);
714
715        let _ = spawn(
716            key,
717            "FailedSpawnTest",
718            Watched::from_spec(&bad),
719            Duration::from_millis(10),
720            || Ok(None),
721        )
722        .expect_err("no directory to watch means the spawn fails");
723
724        assert!(
725            !STARTED.lock().unwrap().contains_key(&key),
726            "a failed spawn must not keep its registration"
727        );
728
729        // The retry gets a real watcher, not an inert one.
730        let handle = spawn(
731            key,
732            "FailedSpawnTest",
733            Watched::from_spec(&explicit_spec()),
734            Duration::from_millis(10),
735            || Ok(None),
736        )
737        .expect("the retry should start a watcher");
738
739        drop(handle);
740        assert!(
741            !STARTED.lock().unwrap().contains_key(&key),
742            "and dropping it frees the registration"
743        );
744    }
745
746    #[test]
747    fn creation_and_removal_both_count_as_changes() {
748        for kind in [
749            EventKind::Create(CreateKind::File),
750            EventKind::Remove(notify::event::RemoveKind::File),
751        ] {
752            let probe = event(kind, "config.toml");
753
754            assert!(
755                is_relevant(&probe, &Watched::from_spec(&explicit_spec())),
756                "{kind:?}"
757            );
758        }
759    }
760}