1use 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
21const DEBOUNCE: Duration = Duration::from_millis(400);
24const REARM_RETRY: Duration = Duration::from_millis(100);
26const REARM_GRACE: Duration = Duration::from_secs(2);
28const REARM_SLOW: Duration = Duration::from_secs(1);
31
32#[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
44pub 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 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
89struct 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
107fn 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
134fn event_matches(event: ¬ify::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
159async 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
189async 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 use super::{ConfigWatch, watch};
225 use std::time::Duration;
226 use tokio::sync::mpsc::UnboundedReceiver;
227
228 const QUIET: Duration = super::DEBOUNCE.saturating_mul(2);
232 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 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 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 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 #[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 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 #[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 #[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 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 #[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 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 #[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}