Skip to main content

forge_agent/env/
config_watch.rs

1//! A watch on exactly one file - `forge.toml` - so an edit reaches a
2//! running forge without a restart.
3//!
4//! The watch is on the file, never its directory: nothing else in the
5//! config dir can produce an event. An editor's atomic save writes a
6//! temporary file and renames it over the path, and Syncthing applies an
7//! incoming change the same way, so the watch is re-armed on the path
8//! after every burst - a watch held on the replaced inode goes deaf.
9//!
10//! A symlink repointed at a different target produces no event, because the
11//! backend watches the target's directory. Replace the file through the
12//! link, or edit the path the config dir actually names.
13
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use notify::{RecommendedWatcher, RecursiveMode, Watcher as _};
18use tokio::sync::mpsc::UnboundedSender;
19use tokio::task::JoinHandle;
20
21/// Coalescing window, measured from a burst's first event. Later events
22/// inside it join the burst and do not extend it.
23const DEBOUNCE: Duration = Duration::from_millis(400);
24/// Re-arm retry while an atomic save has the path momentarily missing.
25const REARM_RETRY: Duration = Duration::from_millis(100);
26/// How long the fast retries run before the missing path is reported.
27const REARM_GRACE: Duration = Duration::from_secs(2);
28/// Re-arm cadence after that, so a file that reappears later is still
29/// watched and its contents apply.
30const REARM_SLOW: Duration = Duration::from_secs(1);
31
32/// A live watch. Dropping it stops the task and with it the watcher.
33#[must_use = "dropping it stops watching forge.toml"]
34pub struct ConfigWatch {
35    task: JoinHandle<()>,
36}
37
38impl Drop for ConfigWatch {
39    fn drop(&mut self) {
40        self.task.abort();
41    }
42}
43
44/// Watch `path` and send one `()` per burst of content changes to it.
45///
46/// # Errors
47///
48/// There is no Tokio runtime to own the watch task, or the initial watcher
49/// could not be created or armed. Whether that is fatal is the caller's
50/// call: a running forge keeps serving and says it is not watching.
51pub fn watch(path: PathBuf, changed: UnboundedSender<()>) -> std::io::Result<ConfigWatch> {
52    let runtime = tokio::runtime::Handle::try_current().map_err(|_| {
53        std::io::Error::other("config_watch::watch needs a Tokio runtime to own the watch")
54    })?;
55    let (events, mut incoming) = tokio::sync::mpsc::unbounded_channel::<()>();
56    let mut watcher = Some(arm(&path, &events)?);
57    let task = runtime.spawn(async move {
58        // The task's own sender keeps this channel open for the watch's
59        // life, so a read never ends it; dropping the handle does.
60        loop {
61            let _ = incoming.recv().await;
62            let deadline = tokio::time::Instant::now() + DEBOUNCE;
63            loop {
64                tokio::select! {
65                    () = tokio::time::sleep_until(deadline) => break,
66                    Some(()) = incoming.recv() => {}
67                }
68            }
69            while incoming.try_recv().is_ok() {}
70
71            drop(watcher.take());
72            watcher = rearm_fast(&path, &events).await;
73            let rearmed = watcher.is_some();
74            if changed.send(()).is_err() {
75                break;
76            }
77            if !rearmed {
78                watcher = slow_rearm(&path, &events, &changed).await;
79                if watcher.is_none() {
80                    break;
81                }
82            }
83        }
84        drop(watcher);
85    });
86    Ok(ConfigWatch { task })
87}
88
89/// Both spellings of the watched path: FSEvents reports real paths, so a
90/// config dir reached through a symlink (`/var` on macOS) needs the
91/// canonical one to match.
92struct WatchedPath {
93    raw: PathBuf,
94    canonical: Option<PathBuf>,
95}
96
97impl WatchedPath {
98    fn new(path: &Path) -> Self {
99        Self { raw: path.to_path_buf(), canonical: path.canonicalize().ok() }
100    }
101
102    fn matches(&self, event: &Path) -> bool {
103        event == self.raw || self.canonical.as_deref() == Some(event)
104    }
105}
106
107/// A notify failure as the `io::Error` the re-arm classifies on.
108/// `std::io::Error::other` would erase the one kind that matters here:
109/// a path an atomic save has momentarily removed is expected, and
110/// everything else is forge's own problem.
111fn arm_io(error: notify::Error) -> std::io::Error {
112    let kind = match &error.kind {
113        notify::ErrorKind::PathNotFound => std::io::ErrorKind::NotFound,
114        notify::ErrorKind::Io(io) => io.kind(),
115        notify::ErrorKind::Generic(_)
116        | notify::ErrorKind::WatchNotFound
117        | notify::ErrorKind::InvalidConfig(_)
118        | notify::ErrorKind::MaxFilesWatch => std::io::ErrorKind::Other,
119    };
120    std::io::Error::new(kind, error)
121}
122
123fn unwatchable(path: &Path, error: &std::io::Error) {
124    tracing::warn!(
125        target: "forge_agent::env::config_watch",
126        event_name = "config_watch_rearm_failed",
127        path = %path.display(),
128        error = %error,
129        "forge.toml could not be watched after an edit; the last good \
130         config stays live and the watch keeps trying",
131    );
132}
133
134/// Whether one notify event means the watched file may have changed. A
135/// rescan says events were dropped and anything may have moved, and it
136/// carries no paths, so it is decided before the path test.
137fn event_matches(event: &notify::Event, target: &WatchedPath) -> bool {
138    if event.need_rescan() {
139        return true;
140    }
141    super::is_content_change(event.kind)
142        && event.paths.iter().any(|event_path| target.matches(event_path))
143}
144
145fn arm(path: &Path, events: &UnboundedSender<()>) -> std::io::Result<RecommendedWatcher> {
146    let target = WatchedPath::new(path);
147    let events = events.clone();
148    let mut watcher = notify::recommended_watcher(move |result: notify::Result<notify::Event>| {
149        let Ok(event) = result else { return };
150        if event_matches(&event, &target) {
151            let _ = events.send(());
152        }
153    })
154    .map_err(arm_io)?;
155    watcher.watch(path, RecursiveMode::NonRecursive).map_err(arm_io)?;
156    Ok(watcher)
157}
158
159/// Re-arm for up to [`REARM_GRACE`], covering the gap an atomic save
160/// leaves. `None` means the path is still missing; the caller sends its
161/// notification anyway and [`slow_rearm`] keeps trying.
162async fn rearm_fast(path: &Path, events: &UnboundedSender<()>) -> Option<RecommendedWatcher> {
163    let deadline = tokio::time::Instant::now() + REARM_GRACE;
164    let mut warned = false;
165    loop {
166        match arm(path, events) {
167            Ok(watcher) => return Some(watcher),
168            Err(error) => {
169                if error.kind() != std::io::ErrorKind::NotFound && !warned {
170                    unwatchable(path, &error);
171                    warned = true;
172                }
173                if tokio::time::Instant::now() >= deadline {
174                    tracing::debug!(
175                        target: "forge_agent::env::config_watch",
176                        event_name = "config_watch_rearm_waiting",
177                        path = %path.display(),
178                        error = %error,
179                        "the config file is not back yet; the last good config stays live",
180                    );
181                    return None;
182                }
183                tokio::time::sleep(REARM_RETRY).await;
184            }
185        }
186    }
187}
188
189/// The path was missing past the grace window: keep a slow watch attempt
190/// running so a file that comes back later is seen and applies.
191async fn slow_rearm(
192    path: &Path,
193    events: &UnboundedSender<()>,
194    changed: &UnboundedSender<()>,
195) -> Option<RecommendedWatcher> {
196    let mut warned = false;
197    loop {
198        tokio::time::sleep(REARM_SLOW).await;
199        if changed.is_closed() {
200            return None;
201        }
202        match arm(path, events) {
203            Ok(watcher) => {
204                let _ = changed.send(());
205                return Some(watcher);
206            }
207            Err(error) => {
208                if error.kind() != std::io::ErrorKind::NotFound && !warned {
209                    unwatchable(path, &error);
210                    warned = true;
211                }
212            }
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    //! **The delivery tests are macOS-ignored.** fseventsd has stalled a report
220    //! for 407 s under a full local gate, which no bound absorbs; they run on
221    //! inotify in CI, and on a quiet Mac with `--run-ignored ignored-only`.
222    //! `a_rescan_matches_and_a_read_does_not` needs no delivery and runs
223    //! everywhere.
224    use super::{ConfigWatch, watch};
225    use std::time::Duration;
226    use tokio::sync::mpsc::UnboundedReceiver;
227
228    /// Two of the watch's own debounce windows: the code closes a burst
229    /// [`super::DEBOUNCE`] after its first event, so this is that window with
230    /// the same again as margin.
231    const QUIET: Duration = super::DEBOUNCE.saturating_mul(2);
232    /// The wait ends when the watch reports, so this bound is reached when no
233    /// report arrives within it: a stuck watch, or a delivery slower still.
234    /// It sits under nextest's 120 s kill, where these run, so that failure
235    /// names the property instead of dying as a bare timeout.
236    const WEDGE: Duration = Duration::from_secs(100);
237
238    async fn fixture() -> (tempfile::TempDir, std::path::PathBuf, UnboundedReceiver<()>, ConfigWatch)
239    {
240        let dir = tempfile::tempdir().expect("tempdir");
241        let path = dir.path().join("forge.toml");
242        std::fs::write(&path, "initial = true\n").expect("write config");
243        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
244        let watch = watch(path.clone(), tx).expect("watch starts");
245        // The stream resolves its start after `watch()` returns, so the
246        // rewrite that proves it live can wait on the daemon for as long as
247        // the machine is loaded. Wait for that burst so each test begins
248        // from a quiet watch rather than racing the fixture's own write.
249        std::fs::write(&path, "initial = true\n").expect("settle write");
250        next(&mut rx, "the watch reports the fixture's own write").await;
251        (dir, path, rx, watch)
252    }
253
254    /// The wait is the stream, not a clock. fsevents holds a delivery for as
255    /// long as the machine is loaded, so the bound here is a wedge detector
256    /// rather than a delivery budget: the wait itself ends when the watch
257    /// reports, so only a stuck watch, or a delivery slower than the bound,
258    /// reaches [`WEDGE`].
259    async fn next(rx: &mut UnboundedReceiver<()>, why: &str) {
260        match tokio::time::timeout(WEDGE, rx.recv()).await {
261            Ok(Some(())) => {}
262            Ok(None) => panic!("{why}: the watch task ended without reporting"),
263            Err(elapsed) => {
264                panic!("{why}: the watch reported nothing within {WEDGE:?} ({elapsed})")
265            }
266        }
267    }
268
269    #[cfg_attr(target_os = "macos", ignore = "fsevents stalls under a full gate")]
270    #[tokio::test]
271    async fn a_plain_write_notifies_once() {
272        let (_dir, path, mut rx, _watch) = fixture().await;
273        std::fs::write(&path, "changed = 1\n").expect("write");
274        next(&mut rx, "a write to the watched file notifies").await;
275        assert!(
276            tokio::time::timeout(QUIET, rx.recv()).await.is_err(),
277            "one write is one notification, not a second after the burst",
278        );
279        // The quiet window is evidence only while the watch can still
280        // report; a dead watch would pass it too, so a write now must
281        // report.
282        std::fs::write(&path, "again = 1\n").expect("write");
283        next(&mut rx, "the watch still reports after a single write").await;
284    }
285
286    /// The reload's own read must not feed the watch. inotify reports a read
287    /// of a single watched file as an access event; accepting it would make
288    /// every reload trigger the next one.
289    #[cfg_attr(target_os = "macos", ignore = "fsevents stalls under a full gate")]
290    #[tokio::test]
291    async fn a_read_does_not_notify() {
292        let (_dir, path, mut rx, _watch) = fixture().await;
293        std::fs::write(&path, "before the read = 1\n").expect("write");
294        next(&mut rx, "the watch is live before the read").await;
295        assert!(
296            tokio::time::timeout(QUIET, rx.recv()).await.is_err(),
297            "one write is one notification",
298        );
299        std::fs::read_to_string(&path).expect("read the watched file");
300        assert!(
301            tokio::time::timeout(QUIET, rx.recv()).await.is_err(),
302            "reading the watched file is not a content change",
303        );
304        // The quiet windows are evidence only while the watch can still
305        // report; a dead watch would pass them too, so a write now must
306        // report.
307        std::fs::write(&path, "after the read = 1\n").expect("write");
308        next(&mut rx, "the watch still reports after the read").await;
309    }
310
311    /// A rescan says events were dropped and names no path, so it must
312    /// reach the caller anyway; a read names the file and still must not.
313    #[test]
314    fn a_rescan_matches_and_a_read_does_not() {
315        use notify::event::{AccessKind, EventKind, Flag};
316
317        let dir = tempfile::tempdir().expect("tempdir");
318        let path = dir.path().join("forge.toml");
319        let target = super::WatchedPath::new(&path);
320
321        let rescan = notify::Event::new(EventKind::Other).set_flag(Flag::Rescan);
322        assert!(super::event_matches(&rescan, &target), "a rescan triggers a reload");
323
324        let read = notify::Event::new(EventKind::Access(AccessKind::Any)).add_path(path);
325        assert!(!super::event_matches(&read, &target), "a read is not a content change");
326    }
327
328    /// **The re-arm is what the Linux run proves.** macOS FSEvents survives a
329    /// rename-over without it, so a green run there says nothing about the
330    /// re-arm; the second write is the assertion that matters on Linux.
331    #[cfg_attr(target_os = "macos", ignore = "fsevents stalls under a full gate")]
332    #[tokio::test]
333    async fn an_atomic_save_notifies_and_the_watch_survives_the_rename() {
334        let (_dir, path, mut rx, _watch) = fixture().await;
335        let tmp = path.with_extension("toml.tmp");
336        std::fs::write(&tmp, "replaced = 1\n").expect("write temp");
337        std::fs::rename(&tmp, &path).expect("rename over the config");
338        next(&mut rx, "a temp-file-plus-rename notifies").await;
339
340        std::fs::write(&path, "replaced = 2\n").expect("write after the rename");
341        next(&mut rx, "the watch was re-armed on the path, so the next save still notifies").await;
342    }
343
344    #[cfg_attr(target_os = "macos", ignore = "fsevents stalls under a full gate")]
345    #[tokio::test]
346    async fn two_rapid_writes_coalesce_into_one() {
347        let (_dir, path, mut rx, _watch) = fixture().await;
348        std::fs::write(&path, "first = 1\n").expect("first write");
349        std::fs::write(&path, "second = 1\n").expect("second write");
350        next(&mut rx, "the burst notifies").await;
351        assert!(
352            tokio::time::timeout(QUIET, rx.recv()).await.is_err(),
353            "both writes land in one burst, so the caller reloads once",
354        );
355        // The quiet window is evidence only while the watch can still
356        // report; a dead watch would pass it too, so a write now must
357        // report.
358        std::fs::write(&path, "third = 1\n").expect("third write");
359        next(&mut rx, "the watch still reports after the coalesced burst").await;
360    }
361
362    /// The negative control carries its own positive half: a watched write
363    /// notifies exactly once before the sibling write proves silence, so a
364    /// dead watcher cannot pass the test.
365    #[cfg_attr(target_os = "macos", ignore = "fsevents stalls under a full gate")]
366    #[tokio::test]
367    async fn a_sibling_file_notifies_nothing() {
368        let (_dir, path, mut rx, _watch) = fixture().await;
369        std::fs::write(&path, "changed = 1\n").expect("write the watched file");
370        next(&mut rx, "the watched file still notifies").await;
371        assert!(
372            tokio::time::timeout(QUIET, rx.recv()).await.is_err(),
373            "one watched write is one notification",
374        );
375
376        std::fs::write(path.with_file_name("other.toml"), "sibling = 1\n").expect("sibling");
377        assert!(
378            tokio::time::timeout(QUIET, rx.recv()).await.is_err(),
379            "only the watched path produces an event",
380        );
381        // The quiet windows are evidence only while the watch can still
382        // report; a dead watch would pass them too, so a write now must
383        // report.
384        std::fs::write(&path, "after the sibling = 1\n").expect("write");
385        next(&mut rx, "the watch still reports after the sibling").await;
386    }
387
388    /// A file that stays missing past the grace window is still reported, and
389    /// the slow re-arm deliberately keeps trying so the file's return applies.
390    #[cfg_attr(target_os = "macos", ignore = "fsevents stalls under a full gate")]
391    #[tokio::test]
392    async fn a_deleted_file_still_notifies_and_its_return_applies() {
393        let (_dir, path, mut rx, _watch) = fixture().await;
394        std::fs::remove_file(&path).expect("remove");
395        next(
396            &mut rx,
397            "a file gone past the grace window still reaches the caller, so the missing \
398             path is reported",
399        )
400        .await;
401
402        std::fs::write(&path, "back = 1\n").expect("recreate");
403        next(&mut rx, "a config that comes back is seen again, so its contents apply").await;
404    }
405}