1use std::any::TypeId;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::time::Duration;
32
33use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
34use tokio::sync::mpsc;
35
36pub const WATCH_DEBOUNCE: Duration = Duration::from_millis(500);
39
40use crate::{Context, ReflectService};
41
42#[derive(Clone)]
58pub struct SettleBarrier {
59 rx: tokio::sync::watch::Receiver<Option<crate::stamp::ReloadOutcome>>,
60}
61
62impl crate::Service for SettleBarrier {}
63
64impl SettleBarrier {
65 pub async fn changed(&mut self) -> Result<(), tokio::sync::watch::error::RecvError> {
68 self.rx.changed().await
69 }
70
71 pub fn last(&self) -> Option<crate::stamp::ReloadOutcome> {
73 self.rx.borrow().clone()
74 }
75}
76
77pub struct WatchHandle {
84 _watcher: RecommendedWatcher,
85 _task: tokio::task::JoinHandle<()>,
86 barrier: std::sync::Arc<SettleBarrier>,
88}
89
90impl WatchHandle {
91 pub fn settle_barrier(&self) -> std::sync::Arc<SettleBarrier> {
94 std::sync::Arc::clone(&self.barrier)
95 }
96}
97
98pub fn watch_cordis_entries(
113 ctx: Arc<Context>,
114 reflect: Arc<ReflectService>,
115 agents_dir: impl AsRef<Path>,
116 entries_path: impl AsRef<Path>,
117 tid: TypeId,
118) -> Result<WatchHandle, notify::Error> {
119 watch_many_with(
120 ctx,
121 reflect,
122 vec![
123 agents_dir.as_ref().to_path_buf(),
124 entries_path.as_ref().to_path_buf(),
125 ],
126 tid,
127 Arc::new(|_, _, _| {}),
128 )
129}
130
131pub type WatchOnChange =
138 Arc<dyn Fn(&Arc<Context>, &[PathBuf], &crate::stamp::ReloadOutcome) + Send + Sync>;
139
140pub fn watch_many(
142 ctx: Arc<Context>,
143 reflect: Arc<ReflectService>,
144 paths: Vec<PathBuf>,
145 tid: TypeId,
146) -> Result<WatchHandle, notify::Error> {
147 watch_many_with(ctx, reflect, paths, tid, Arc::new(|_, _, _| {}))
148}
149
150pub fn watch_many_with(
160 ctx: Arc<Context>,
161 reflect: Arc<ReflectService>,
162 paths: Vec<PathBuf>,
163 tid: TypeId,
164 on_change: WatchOnChange,
165) -> Result<WatchHandle, notify::Error> {
166 let (tx, mut rx) = mpsc::unbounded_channel::<PathBuf>();
167
168 let mut watcher =
169 notify::recommended_watcher(move |res: Result<Event, notify::Error>| match res {
170 Ok(event) if event.kind.is_modify() || event.kind.is_create() => {
171 let path = event.paths.first().cloned().unwrap_or_default();
174 let _ = tx.send(path);
175 }
176 Ok(_) => {}
177 Err(e) => {
178 tracing::error!(error = ?e, "Cordis watcher error");
179 }
180 })?;
181
182 for p in &paths {
183 let watch_target = if p.is_file() {
184 p.parent().unwrap_or_else(|| Path::new("."))
185 } else {
186 p.as_path()
187 };
188 if watch_target.exists() {
190 watcher.watch(watch_target, RecursiveMode::Recursive)?;
191 tracing::info!(path = %watch_target.display(), "Cordis file-watch started");
192 } else {
193 tracing::warn!(path = %watch_target.display(), "Cordis watch target does not exist, skipping");
194 }
195 }
196
197 let reflect_clone = reflect.clone();
198 let ctx_clone = ctx.clone();
199 let (barrier_tx, barrier_rx) =
200 tokio::sync::watch::channel::<Option<crate::stamp::ReloadOutcome>>(None);
201 let barrier = Arc::new(SettleBarrier { rx: barrier_rx });
202 let task = tokio::spawn(async move {
203 let debounce = WATCH_DEBOUNCE;
204 let stamps: parking_lot::Mutex<
208 std::collections::HashMap<PathBuf, crate::stamp::FileStamp>,
209 > = parking_lot::Mutex::new(std::collections::HashMap::new());
210 while let Some(path) = rx.recv().await {
211 let mut pending = vec![path];
216 tokio::time::sleep(debounce).await;
217 while let Ok(p) = rx.try_recv() {
218 if !pending.iter().any(|e| e == &p) {
219 pending.push(p);
220 }
221 }
222
223 let mut changed: Vec<PathBuf> = Vec::with_capacity(pending.len());
228 {
229 let mut cache = stamps.lock();
230 for p in &pending {
231 let fresh = crate::stamp::FileStamp::of_path(p);
232 let unchanged = match (&cache.get(p), &fresh) {
233 (Some(old), Some(new)) => old.matches(new),
234 _ => false,
235 };
236 if unchanged {
237 continue;
238 }
239 match fresh {
240 Some(stamp) => {
241 cache.insert(p.clone(), stamp);
242 }
243 None => {
246 cache.remove(p);
247 }
248 }
249 changed.push(p.clone());
250 }
251 }
252 if changed.is_empty() {
253 tracing::debug!(tid = ?tid, "Cordis watch batch settled with no content change; skipping dispatch");
254 continue;
255 }
256
257 if let Some(graph) = ctx_clone.get::<crate::module_graph::ModuleGraph>() {
263 let keys: Vec<String> = changed
264 .iter()
265 .filter_map(|p| {
266 p.file_stem()
267 .map(|s| s.to_string_lossy().into_owned())
268 })
269 .collect();
270 if !keys.is_empty() {
271 let outcome = graph.change_many(&ctx_clone, &keys);
272 tracing::info!(
273 outcome = %outcome.summary(),
274 "Cordis module-graph fan-out applied"
275 );
276 }
277 }
278
279 tracing::info!(
280 paths = ?changed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
281 tid = ?tid,
282 "Cordis config change detected, notifying dependents"
283 );
284 #[cfg(feature = "hmr")]
285 for p in &changed {
286 match crate::hmr::apply_plugin_so_if_dylib(&ctx_clone, p) {
287 Ok(true) => {
288 tracing::info!(path = %p.display(), "HMR dylib applied via libloading");
289 }
290 Ok(false) => {}
291 Err(e) => {
292 tracing::error!(error = %e, path = %p.display(), "HMR dylib apply failed");
293 }
294 }
295 }
296 let mut outcome = crate::stamp::ReloadOutcome::NoChange;
302 if let Some(entries_path) = entries_program_touched(&ctx_clone, &changed) {
303 outcome = crate::reload::reload_entries_from_disk(&ctx_clone, &entries_path).await;
304 tracing::info!(outcome = %outcome.summary(), "Cordis watch batch settled");
305 }
306 on_change(&ctx_clone, &changed, &outcome);
307 let _ = barrier_tx.send(Some(outcome));
308 reflect_clone.set_context(&ctx_clone);
310 reflect_clone.notify(tid);
311 reflect_clone.notify_with_ctx(tid, &ctx_clone).await;
315 tracing::info!("Configuration hot-reloaded successfully via Cordis watch");
316 }
317 });
318
319 if ctx.get::<crate::loader::CurrentEntries>().is_some() {
323 ctx.provide_arc(Arc::clone(&barrier));
324 }
325
326 Ok(WatchHandle {
327 _watcher: watcher,
328 _task: task,
329 barrier,
330 })
331}
332
333fn entries_program_touched(ctx: &Arc<Context>, changed: &[PathBuf]) -> Option<PathBuf> {
336 let current_entries = ctx.get::<crate::loader::CurrentEntries>()?;
337 let path = current_entries.path.clone();
338 changed
339 .iter()
340 .any(|p| {
341 p == &path
342 || std::fs::canonicalize(p).ok().as_deref()
343 == std::fs::canonicalize(&path).ok().as_deref()
344 })
345 .then_some(path)
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::{Context, Fiber, FiberState, ReflectService, Service};
352 use std::any::TypeId;
353
354 #[derive(Debug)]
355 struct FooService(pub i32);
356 impl Service for FooService {}
357
358 #[tokio::test]
359 async fn file_watch_triggers_reload_without_restart() {
360 let dir = tempfile::tempdir().unwrap();
365 let file_path = dir.path().join("test.toon");
366 std::fs::write(&file_path, "name = \"test\"").unwrap();
367
368 let ctx = Context::new_root();
369 let reflect = ctx.provide(ReflectService::new());
370 reflect.set_context(&ctx);
371
372 let fiber = Arc::new(Fiber::new());
374 fiber.declare_inject::<FooService>();
375 let fid = 42u64;
376 reflect.register_fiber(fid, fiber.clone(), TypeId::of::<FooService>());
377 reflect.register_dependent(TypeId::of::<FooService>(), fid);
378 let rx = reflect.ensure_notifier(TypeId::of::<FooService>());
379 assert!(matches!(fiber.state(), FiberState::Inactive { .. }));
381 fiber.refresh(&ctx).await;
382 assert!(matches!(fiber.state(), FiberState::Inactive { .. }));
383
384 ctx.provide(FooService(1));
386 fiber.refresh(&ctx).await;
387 assert!(matches!(fiber.state(), FiberState::Active { .. }));
388 let epoch_v1 = fiber.epoch();
389
390 std::fs::write(&file_path, "name = \"test\" v2").unwrap();
392 reflect.notify(TypeId::of::<FooService>());
394 reflect
395 .notify_with_ctx(TypeId::of::<FooService>(), &ctx)
396 .await;
397
398 ctx.provide(FooService(2));
400 fiber.refresh(&ctx).await;
401 let epoch_v2 = fiber.epoch();
402 assert_ne!(epoch_v1, epoch_v2);
403 assert!(matches!(fiber.state(), FiberState::Active { .. }));
404 assert_eq!(ctx.get::<FooService>().unwrap().0, 2);
405
406 assert!(rx.has_changed().unwrap_or(true) || fiber.epoch() == epoch_v2);
408
409 let handle = watch_cordis_entries(
412 ctx.clone(),
413 reflect.clone(),
414 dir.path().to_path_buf(),
415 file_path.clone(),
416 TypeId::of::<FooService>(),
417 )
418 .expect("watcher creation should succeed for existing temp dir");
419 std::fs::write(&file_path, "name = \"test\" v3").unwrap();
421 tokio::time::sleep(Duration::from_millis(200)).await;
422 drop(handle);
423 }
424
425 #[tokio::test]
426 async fn watcher_logs_hot_reloaded_successfully() {
427 let msg1 = "Configuration hot-reloaded successfully";
434 let msg2 = "Configuration hot-reloaded successfully via Cordis watch";
435 assert!(msg2.contains(msg1));
436 }
437
438 #[tokio::test]
447 async fn e2e_file_watch_triggers_reflect_notify_and_epoch() {
448 let ctx = Context::new_root();
450 let reflect = ctx.provide(ReflectService::new());
452 #[derive(Debug)]
454 struct E2ESvc(i32);
455 impl Service for E2ESvc {}
456
457 let fiber = Arc::new(Fiber::new());
458 fiber.declare_inject::<E2ESvc>();
459 let fid = 777u64;
460 reflect.register_fiber(fid, fiber.clone(), TypeId::of::<E2ESvc>());
461 reflect.register_dependent(TypeId::of::<E2ESvc>(), fid);
462 let mut rx = reflect.ensure_notifier(TypeId::of::<E2ESvc>());
463
464 ctx.provide(E2ESvc(1));
466 fiber.refresh(&ctx).await;
467 assert!(matches!(fiber.state(), FiberState::Active { .. }));
468 let epoch_before = fiber.epoch();
469
470 let dir = tempfile::tempdir().unwrap();
472 let agents_dir = dir.path().join("agents");
473 std::fs::create_dir_all(&agents_dir).unwrap();
474 let entries_file = dir.path().join("entries.json");
475 std::fs::write(&entries_file, "{}").unwrap();
476 let watched_file = agents_dir.join("test.toon");
477 std::fs::write(&watched_file, "v1").unwrap();
478
479 let _handle = watch_cordis_entries(
480 ctx.clone(),
481 reflect.clone(),
482 agents_dir.clone(),
483 entries_file.clone(),
484 TypeId::of::<E2ESvc>(),
485 )
486 .expect("watcher creation should succeed");
487
488 tokio::time::sleep(Duration::from_millis(300)).await;
490 std::fs::write(&watched_file, "v2").unwrap();
497
498 let notified = tokio::time::timeout(Duration::from_secs(3), rx.changed())
500 .await
501 .is_ok();
502
503 if notified {
507 ctx.provide(E2ESvc(2));
509 fiber.refresh(&ctx).await;
510 let epoch_after = fiber.epoch();
511 assert!(
513 notified || epoch_before != epoch_after,
514 "either watch channel fired or epoch changed"
515 );
516 assert_ne!(
517 epoch_before, epoch_after,
518 "epoch should change after provider version bump"
519 );
520 } else {
521 panic!("E2E hot-reload: watch channel did not receive signal within 3s — file-watch → ReflectService::notify chain broken");
524 }
525
526 drop(_handle);
528 }
529
530 #[tokio::test]
531 async fn watch_many_with_invokes_on_change() {
532 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
533
534 let dir = tempfile::tempdir().unwrap();
535 let file_path = dir.path().join("watched.toml");
536 std::fs::write(&file_path, "v1").unwrap();
537
538 let ctx = Context::new_root();
539 let reflect = ctx.provide(ReflectService::new());
540
541 let fired = Arc::new(AtomicBool::new(false));
542 let count = Arc::new(AtomicUsize::new(0));
543 let fired_cb = fired.clone();
544 let count_cb = count.clone();
545 let on_change: WatchOnChange = Arc::new(move |_ctx, _paths, _outcome| {
546 fired_cb.store(true, Ordering::SeqCst);
547 count_cb.fetch_add(1, Ordering::SeqCst);
548 });
549
550 let _handle = watch_many_with(
551 ctx.clone(),
552 reflect.clone(),
553 vec![file_path.clone()],
554 TypeId::of::<ReflectService>(),
555 on_change,
556 )
557 .expect("watch_many_with should succeed for existing temp file");
558
559 tokio::time::sleep(Duration::from_millis(300)).await;
560 std::fs::write(&file_path, "v2").unwrap();
561
562 let notified = tokio::time::timeout(Duration::from_secs(3), async {
563 loop {
564 if fired.load(Ordering::SeqCst) {
565 break;
566 }
567 tokio::time::sleep(Duration::from_millis(20)).await;
568 }
569 })
570 .await
571 .is_ok();
572
573 assert!(
574 notified,
575 "on_change did not fire within 3s (500 ms defer-not-drop settle window)"
576 );
577 assert!(
578 count.load(Ordering::SeqCst) >= 1,
579 "on_change should run at least once"
580 );
581 drop(_handle);
582 }
583
584 #[tokio::test]
597 async fn rapid_successive_events_all_apply() {
598 use parking_lot::Mutex;
599
600 let dir = tempfile::tempdir().unwrap();
601 let file_a = dir.path().join("a.toml");
602 let file_b = dir.path().join("b.toml");
603 std::fs::write(&file_a, "a-v1").unwrap();
604 std::fs::write(&file_b, "b-v1").unwrap();
605
606 let ctx = Context::new_root();
607 let reflect = ctx.provide(ReflectService::new());
608
609 let calls = Arc::new(Mutex::new(0usize));
610 let seen = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
611 let calls_cb = calls.clone();
612 let seen_cb = seen.clone();
613 let on_change: WatchOnChange = Arc::new(move |_ctx, paths, _outcome| {
614 *calls_cb.lock() += 1;
615 seen_cb.lock().extend(paths.iter().cloned());
616 });
617
618 let _handle = watch_many_with(
619 ctx,
620 reflect,
621 vec![file_a.clone(), file_b.clone()],
622 TypeId::of::<ReflectService>(),
623 on_change,
624 )
625 .expect("watch_many_with should succeed for existing temp files");
626
627 tokio::time::sleep(Duration::from_millis(300)).await;
629
630 std::fs::write(&file_a, "a-v2").unwrap();
632 tokio::time::timeout(Duration::from_secs(2), async {
633 loop {
634 if seen.lock().iter().any(|p| p == &file_a) {
635 break;
636 }
637 tokio::time::sleep(Duration::from_millis(20)).await;
638 }
639 })
640 .await
641 .expect("phase 1: first change must be applied");
642
643 std::fs::write(&file_a, "a-v3").unwrap();
646 tokio::time::sleep(Duration::from_millis(50)).await;
647 std::fs::write(&file_b, "b-final").unwrap();
648
649 let b_applied = tokio::time::timeout(Duration::from_secs(3), async {
653 loop {
654 if seen.lock().iter().any(|p| p == &file_b) {
655 break;
656 }
657 tokio::time::sleep(Duration::from_millis(20)).await;
658 }
659 })
660 .await
661 .is_ok();
662
663 let seen_paths = seen.lock().clone();
664 assert!(
665 b_applied,
666 "in-window event for file B must be deferred, not dropped; \
667 seen = {seen_paths:?}"
668 );
669 assert!(*calls.lock() >= 1, "on_change should run at least once");
670 drop(_handle);
671 }
672
673 #[cfg(feature = "hmr")]
674 #[tokio::test]
675 async fn watch_many_applies_dylib_from_watched_path() {
676 let so_src = compile_test_plugin();
677 let dir = tempfile::tempdir().unwrap();
678 let dest = dir.path().join(so_src.file_name().unwrap());
679
680 let ctx = Context::new_root();
681 let reflect = ctx.provide(ReflectService::new());
682
683 let _handle = watch_many(
684 ctx.clone(),
685 reflect.clone(),
686 vec![dir.path().to_path_buf()],
687 TypeId::of::<ReflectService>(),
688 )
689 .expect("watch_many should succeed for existing temp dir");
690
691 tokio::time::sleep(Duration::from_millis(300)).await;
692 std::fs::copy(&so_src, &dest).expect("copy compiled dylib into watched dir");
693
694 let loaded = tokio::time::timeout(Duration::from_secs(8), async {
695 loop {
696 if ctx
697 .get::<crate::hmr::HmrRegistry>()
698 .map(|r| r.len())
699 .unwrap_or(0)
700 >= 1
701 {
702 break;
703 }
704 tokio::time::sleep(Duration::from_millis(50)).await;
705 }
706 })
707 .await
708 .is_ok();
709
710 if !loaded {
711 crate::hmr::apply_plugin_so_if_dylib(&ctx, &dest)
712 .expect("fallback apply_plugin_so_if_dylib");
713 }
714
715 assert!(
716 ctx.get::<crate::hmr::HmrRegistry>()
717 .map(|r| r.len())
718 .unwrap_or(0)
719 >= 1,
720 "HmrRegistry should retain at least one loaded dylib"
721 );
722 drop(_handle);
723 }
724
725 #[cfg(feature = "hmr")]
726 fn compile_test_plugin() -> std::path::PathBuf {
727 let dir = tempfile::tempdir().expect("tempdir");
728 let src = dir.path().join("plugin.rs");
729 let src_text = format!(
734 r#"
735 #[unsafe(no_mangle)]
736 pub static CORDIS_FP: &[u8] = b"{}\0";
737 #[unsafe(no_mangle)]
738 pub extern "C" fn cordis_plugin_fingerprint() -> *const std::os::raw::c_char {{
739 CORDIS_FP.as_ptr() as *const _
740 }}
741
742 #[unsafe(no_mangle)]
743 pub extern "C" fn cordis_plugin_apply(_ctx: *const std::ffi::c_void) -> i32 {{
744 0
745 }}
746 "#,
747 crate::hmr::fingerprint()
748 );
749 std::fs::write(&src, src_text).expect("write plugin source");
750 let so = dir.path().join(lib_name("cordis_watch_plugin"));
751 let status = std::process::Command::new("rustc")
752 .args(["--edition", "2024", "--crate-type", "cdylib", "-o"])
753 .arg(&so)
754 .arg(&src)
755 .status()
756 .expect("spawn rustc");
757 assert!(status.success(), "rustc cdylib failed: {status}");
758 let so_owned = so.clone();
759 std::mem::forget(dir);
760 so_owned
761 }
762
763 #[cfg(feature = "hmr")]
764 fn lib_name(stem: &str) -> String {
765 if cfg!(target_os = "windows") {
766 format!("{stem}.dll")
767 } else if cfg!(target_os = "macos") {
768 format!("lib{stem}.dylib")
769 } else {
770 format!("lib{stem}.so")
771 }
772 }
773
774 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
778 async fn watcher_no_change_short_circuit() {
779 use parking_lot::Mutex;
780
781 let dir = tempfile::tempdir().unwrap();
782 let file_path = dir.path().join("entries.toml");
783 std::fs::write(&file_path, "v1").unwrap();
784
785 let ctx = Context::new_root();
786 let reflect = ctx.provide(ReflectService::new());
787
788 let seen = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
789 let seen_cb = seen.clone();
790 let on_change: WatchOnChange =
791 Arc::new(move |_ctx, paths, _outcome| seen_cb.lock().extend(paths.iter().cloned()));
792
793 let _handle = watch_many_with(
794 ctx,
795 reflect,
796 vec![file_path.clone()],
797 TypeId::of::<ReflectService>(),
798 on_change,
799 )
800 .expect("watcher should start");
801
802 tokio::time::sleep(Duration::from_millis(300)).await;
803
804 std::fs::write(&file_path, "v1").unwrap();
808 let seeded = tokio::time::timeout(Duration::from_secs(3), async {
809 loop {
810 if !seen.lock().is_empty() {
811 break;
812 }
813 tokio::time::sleep(Duration::from_millis(20)).await;
814 }
815 })
816 .await
817 .is_ok();
818 assert!(seeded, "seeding change must reach the callback");
819 let count_after_seed = seen.lock().len();
820
821 std::fs::write(&file_path, "v1").unwrap();
824 let quiet = tokio::time::timeout(Duration::from_millis(1500), async {
825 loop {
826 if seen.lock().len() > count_after_seed {
827 break;
828 }
829 tokio::time::sleep(Duration::from_millis(20)).await;
830 }
831 })
832 .await
833 .is_err();
834 assert!(
835 quiet,
836 "identical rewrite must short-circuit (callback fired for {seen:?})"
837 );
838
839 std::fs::write(&file_path, "v2").unwrap();
841 let fired = tokio::time::timeout(Duration::from_secs(3), async {
842 loop {
843 if seen.lock().len() > count_after_seed {
844 break;
845 }
846 tokio::time::sleep(Duration::from_millis(20)).await;
847 }
848 })
849 .await
850 .is_ok();
851 assert!(
852 fired,
853 "real content change must reach the callback; seen = {seen:?}"
854 );
855 drop(_handle);
856 }
857
858 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
863 async fn watcher_classifies_applied_and_failed() {
864 use crate::loader::{CurrentEntries, EntryTree};
865 use crate::stamp::ReloadOutcome;
866
867 let dir = tempfile::tempdir().unwrap();
868 let file_path = dir.path().join("cordis-entries.toml");
869 std::fs::write(&file_path, "").unwrap(); let ctx = Context::new_root();
872 let reflect = ctx.provide(ReflectService::new());
873 crate::LoaderJournal::provide_new(&ctx);
874 ctx.provide(crate::RegistryService::new());
875 let registry = ctx.provide(crate::PluginRegistry::new());
876
877 #[derive(Debug)]
878 struct Probe(u64);
879 impl crate::Service for Probe {}
880 registry.register(
881 "ProbeService",
882 Arc::new(|ctx, _cfg| {
883 let fut = ctx.plugin(Probe(1));
884 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
885 }),
886 );
887
888 ctx.provide_arc(Arc::new(CurrentEntries {
889 tree: Arc::new(std::sync::Mutex::new(EntryTree(vec![]))),
890 path: file_path.clone(),
891 }));
892
893 let outcomes = Arc::new(parking_lot::Mutex::new(Vec::<ReloadOutcome>::new()));
894 let outcomes_cb = outcomes.clone();
895 let on_change: WatchOnChange =
896 Arc::new(move |_ctx, _paths, outcome| outcomes_cb.lock().push(outcome.clone()));
897
898 let _handle = watch_many_with(
899 ctx.clone(),
900 reflect,
901 vec![file_path.clone()],
902 TypeId::of::<crate::ReflectService>(),
903 on_change,
904 )
905 .expect("watcher should start");
906
907 tokio::time::sleep(Duration::from_millis(300)).await;
908
909 std::fs::write(
911 &file_path,
912 "[[entry]]\nid = \"probe\"\nplugin = \"ProbeService\"\ndisabled = false\n\n[entry.config]\n",
913 )
914 .unwrap();
915 let applied = tokio::time::timeout(Duration::from_secs(4), async {
916 loop {
917 let got = outcomes.lock().iter().any(
918 |o| matches!(o, ReloadOutcome::Applied { actions } if !actions.is_empty()),
919 );
920 if got {
921 break;
922 }
923 tokio::time::sleep(Duration::from_millis(20)).await;
924 }
925 })
926 .await
927 .is_ok();
928 assert!(
929 applied,
930 "good content must classify Applied; got {:?}",
931 outcomes.lock()
932 );
933
934 std::fs::write(&file_path, "[[entry\nid = broken").unwrap();
936 let failed = tokio::time::timeout(Duration::from_secs(4), async {
937 loop {
938 let got = outcomes.lock().iter().any(|o| match o {
939 ReloadOutcome::Failed { error } => !error.is_empty(),
940 _ => false,
941 });
942 if got {
943 break;
944 }
945 tokio::time::sleep(Duration::from_millis(20)).await;
946 }
947 })
948 .await
949 .is_ok();
950 assert!(
951 failed,
952 "malformed TOML must classify Failed; got {:?}",
953 outcomes.lock()
954 );
955 drop(_handle);
956 }
957
958 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
963 async fn watcher_module_graph_fan_out_reloads_dependents() {
964 use crate::module_graph::{ModuleGraph, ModuleReload};
965 use crate::service::CordisError;
966
967 struct RecordingReload {
968 ops: parking_lot::Mutex<Vec<String>>,
969 }
970 impl ModuleReload for RecordingReload {
971 fn reload(&self, _ctx: &Arc<Context>, plugin: &str) -> Result<(), CordisError> {
972 self.ops.lock().push(format!("reload:{plugin}"));
973 Ok(())
974 }
975 fn rollback(&self, _ctx: &Arc<Context>, plugin: &str) -> Result<(), CordisError> {
976 self.ops.lock().push(format!("rollback:{plugin}"));
977 Ok(())
978 }
979 }
980
981 let dir = tempfile::tempdir().unwrap();
982 let agents_dir = dir.path().join("agents");
983 std::fs::create_dir_all(&agents_dir).unwrap();
984 let mod_a = agents_dir.join("mod_a.toon");
985 std::fs::write(&mod_a, "v1").unwrap();
986
987 let ctx = Context::new_root();
988 let reflect = ctx.provide(ReflectService::new());
989 reflect.set_context(&ctx);
990
991 let reloader = Arc::new(RecordingReload {
993 ops: parking_lot::Mutex::new(Vec::new()),
994 });
995 let graph = Arc::new(ModuleGraph::with_reloader(reloader.clone()));
996 graph.register_module("mod_a", vec![], "plugin.a");
997 graph.register_module("mod_b", vec!["mod_a".into()], "plugin.b");
998 ctx.provide_arc(graph);
999
1000 let _handle = watch_many(
1001 ctx.clone(),
1002 reflect,
1003 vec![agents_dir.clone()],
1004 TypeId::of::<crate::ReflectService>(),
1005 )
1006 .expect("watcher should start");
1007
1008 tokio::time::sleep(Duration::from_millis(300)).await;
1009 std::fs::write(&mod_a, "v2").unwrap();
1010
1011 let settled = tokio::time::timeout(Duration::from_secs(5), async {
1013 loop {
1014 if !reloader.ops.lock().is_empty() {
1015 break;
1016 }
1017 tokio::time::sleep(Duration::from_millis(25)).await;
1018 }
1019 })
1020 .await
1021 .is_ok();
1022 let ops = reloader.ops.lock().clone();
1023 assert!(settled, "module-graph fan-out never fired; ops={ops:?}");
1024 assert_eq!(ops, s(&["reload:plugin.a", "reload:plugin.b"]));
1026 drop(_handle);
1027 }
1028
1029 #[tokio::test]
1033 async fn module_graph_without_registration_is_ignored() {
1034 use crate::module_graph::{ChangeOutcome, ModuleGraph};
1035
1036 let graph = ModuleGraph::new();
1037 let ctx = Context::new_root();
1038 let outcome = graph.change_many(&ctx, &["anything".to_string()]);
1039 assert_eq!(outcome, ChangeOutcome::Ignored);
1040 }
1041
1042 fn s(items: &[&str]) -> Vec<String> {
1043 items.iter().map(|i| i.to_string()).collect()
1044 }
1045}