use std::any::TypeId;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::mpsc;
pub const WATCH_DEBOUNCE: Duration = Duration::from_millis(500);
use crate::{Context, ReflectService};
#[derive(Clone)]
pub struct SettleBarrier {
rx: tokio::sync::watch::Receiver<Option<crate::stamp::ReloadOutcome>>,
}
impl crate::Service for SettleBarrier {}
impl SettleBarrier {
pub async fn changed(&mut self) -> Result<(), tokio::sync::watch::error::RecvError> {
self.rx.changed().await
}
pub fn last(&self) -> Option<crate::stamp::ReloadOutcome> {
self.rx.borrow().clone()
}
}
pub struct WatchHandle {
_watcher: RecommendedWatcher,
_task: tokio::task::JoinHandle<()>,
barrier: std::sync::Arc<SettleBarrier>,
}
impl WatchHandle {
pub fn settle_barrier(&self) -> std::sync::Arc<SettleBarrier> {
std::sync::Arc::clone(&self.barrier)
}
}
pub fn watch_cordis_entries(
ctx: Arc<Context>,
reflect: Arc<ReflectService>,
agents_dir: impl AsRef<Path>,
entries_path: impl AsRef<Path>,
tid: TypeId,
) -> Result<WatchHandle, notify::Error> {
watch_many_with(
ctx,
reflect,
vec![
agents_dir.as_ref().to_path_buf(),
entries_path.as_ref().to_path_buf(),
],
tid,
Arc::new(|_, _, _| {}),
)
}
pub type WatchOnChange =
Arc<dyn Fn(&Arc<Context>, &[PathBuf], &crate::stamp::ReloadOutcome) + Send + Sync>;
pub fn watch_many(
ctx: Arc<Context>,
reflect: Arc<ReflectService>,
paths: Vec<PathBuf>,
tid: TypeId,
) -> Result<WatchHandle, notify::Error> {
watch_many_with(ctx, reflect, paths, tid, Arc::new(|_, _, _| {}))
}
pub fn watch_many_with(
ctx: Arc<Context>,
reflect: Arc<ReflectService>,
paths: Vec<PathBuf>,
tid: TypeId,
on_change: WatchOnChange,
) -> Result<WatchHandle, notify::Error> {
let (tx, mut rx) = mpsc::unbounded_channel::<PathBuf>();
let mut watcher =
notify::recommended_watcher(move |res: Result<Event, notify::Error>| match res {
Ok(event) if event.kind.is_modify() || event.kind.is_create() => {
let path = event.paths.first().cloned().unwrap_or_default();
let _ = tx.send(path);
}
Ok(_) => {}
Err(e) => {
tracing::error!(error = ?e, "Cordis watcher error");
}
})?;
for p in &paths {
let watch_target = if p.is_file() {
p.parent().unwrap_or_else(|| Path::new("."))
} else {
p.as_path()
};
if watch_target.exists() {
watcher.watch(watch_target, RecursiveMode::Recursive)?;
tracing::info!(path = %watch_target.display(), "Cordis file-watch started");
} else {
tracing::warn!(path = %watch_target.display(), "Cordis watch target does not exist, skipping");
}
}
let reflect_clone = reflect.clone();
let ctx_clone = ctx.clone();
let (barrier_tx, barrier_rx) =
tokio::sync::watch::channel::<Option<crate::stamp::ReloadOutcome>>(None);
let barrier = Arc::new(SettleBarrier { rx: barrier_rx });
let task = tokio::spawn(async move {
let debounce = WATCH_DEBOUNCE;
let stamps: parking_lot::Mutex<
std::collections::HashMap<PathBuf, crate::stamp::FileStamp>,
> = parking_lot::Mutex::new(std::collections::HashMap::new());
while let Some(path) = rx.recv().await {
let mut pending = vec![path];
tokio::time::sleep(debounce).await;
while let Ok(p) = rx.try_recv() {
if !pending.iter().any(|e| e == &p) {
pending.push(p);
}
}
let mut changed: Vec<PathBuf> = Vec::with_capacity(pending.len());
{
let mut cache = stamps.lock();
for p in &pending {
let fresh = crate::stamp::FileStamp::of_path(p);
let unchanged = match (&cache.get(p), &fresh) {
(Some(old), Some(new)) => old.matches(new),
_ => false,
};
if unchanged {
continue;
}
match fresh {
Some(stamp) => {
cache.insert(p.clone(), stamp);
}
None => {
cache.remove(p);
}
}
changed.push(p.clone());
}
}
if changed.is_empty() {
tracing::debug!(tid = ?tid, "Cordis watch batch settled with no content change; skipping dispatch");
continue;
}
if let Some(graph) = ctx_clone.get::<crate::module_graph::ModuleGraph>() {
let keys: Vec<String> = changed
.iter()
.filter_map(|p| {
p.file_stem()
.map(|s| s.to_string_lossy().into_owned())
})
.collect();
if !keys.is_empty() {
let outcome = graph.change_many(&ctx_clone, &keys);
tracing::info!(
outcome = %outcome.summary(),
"Cordis module-graph fan-out applied"
);
}
}
tracing::info!(
paths = ?changed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
tid = ?tid,
"Cordis config change detected, notifying dependents"
);
#[cfg(feature = "hmr")]
for p in &changed {
match crate::hmr::apply_plugin_so_if_dylib(&ctx_clone, p) {
Ok(true) => {
tracing::info!(path = %p.display(), "HMR dylib applied via libloading");
}
Ok(false) => {}
Err(e) => {
tracing::error!(error = %e, path = %p.display(), "HMR dylib apply failed");
}
}
}
let mut outcome = crate::stamp::ReloadOutcome::NoChange;
if let Some(entries_path) = entries_program_touched(&ctx_clone, &changed) {
outcome = crate::reload::reload_entries_from_disk(&ctx_clone, &entries_path).await;
tracing::info!(outcome = %outcome.summary(), "Cordis watch batch settled");
}
on_change(&ctx_clone, &changed, &outcome);
let _ = barrier_tx.send(Some(outcome));
reflect_clone.set_context(&ctx_clone);
reflect_clone.notify(tid);
reflect_clone.notify_with_ctx(tid, &ctx_clone).await;
tracing::info!("Configuration hot-reloaded successfully via Cordis watch");
}
});
if ctx.get::<crate::loader::CurrentEntries>().is_some() {
ctx.provide_arc(Arc::clone(&barrier));
}
Ok(WatchHandle {
_watcher: watcher,
_task: task,
barrier,
})
}
fn entries_program_touched(ctx: &Arc<Context>, changed: &[PathBuf]) -> Option<PathBuf> {
let current_entries = ctx.get::<crate::loader::CurrentEntries>()?;
let path = current_entries.path.clone();
changed
.iter()
.any(|p| {
p == &path
|| std::fs::canonicalize(p).ok().as_deref()
== std::fs::canonicalize(&path).ok().as_deref()
})
.then_some(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Context, Fiber, FiberState, ReflectService, Service};
use std::any::TypeId;
#[derive(Debug)]
struct FooService(pub i32);
impl Service for FooService {}
#[tokio::test]
async fn file_watch_triggers_reload_without_restart() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.toon");
std::fs::write(&file_path, "name = \"test\"").unwrap();
let ctx = Context::new_root();
let reflect = ctx.provide(ReflectService::new());
reflect.set_context(&ctx);
let fiber = Arc::new(Fiber::new());
fiber.declare_inject::<FooService>();
let fid = 42u64;
reflect.register_fiber(fid, fiber.clone(), TypeId::of::<FooService>());
reflect.register_dependent(TypeId::of::<FooService>(), fid);
let rx = reflect.ensure_notifier(TypeId::of::<FooService>());
assert!(matches!(fiber.state(), FiberState::Inactive { .. }));
fiber.refresh(&ctx).await;
assert!(matches!(fiber.state(), FiberState::Inactive { .. }));
ctx.provide(FooService(1));
fiber.refresh(&ctx).await;
assert!(matches!(fiber.state(), FiberState::Active { .. }));
let epoch_v1 = fiber.epoch();
std::fs::write(&file_path, "name = \"test\" v2").unwrap();
reflect.notify(TypeId::of::<FooService>());
reflect
.notify_with_ctx(TypeId::of::<FooService>(), &ctx)
.await;
ctx.provide(FooService(2));
fiber.refresh(&ctx).await;
let epoch_v2 = fiber.epoch();
assert_ne!(epoch_v1, epoch_v2);
assert!(matches!(fiber.state(), FiberState::Active { .. }));
assert_eq!(ctx.get::<FooService>().unwrap().0, 2);
assert!(rx.has_changed().unwrap_or(true) || fiber.epoch() == epoch_v2);
let handle = watch_cordis_entries(
ctx.clone(),
reflect.clone(),
dir.path(),
file_path.clone(),
TypeId::of::<FooService>(),
)
.expect("watcher creation should succeed for existing temp dir");
std::fs::write(&file_path, "name = \"test\" v3").unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
drop(handle);
}
#[tokio::test]
async fn watcher_logs_hot_reloaded_successfully() {
let msg1 = "Configuration hot-reloaded successfully";
let msg2 = "Configuration hot-reloaded successfully via Cordis watch";
assert!(msg2.contains(msg1));
}
#[tokio::test]
async fn e2e_file_watch_triggers_reflect_notify_and_epoch() {
let ctx = Context::new_root();
let reflect = ctx.provide(ReflectService::new());
#[derive(Debug)]
struct E2ESvc(i32);
impl Service for E2ESvc {}
let fiber = Arc::new(Fiber::new());
fiber.declare_inject::<E2ESvc>();
let fid = 777u64;
reflect.register_fiber(fid, fiber.clone(), TypeId::of::<E2ESvc>());
reflect.register_dependent(TypeId::of::<E2ESvc>(), fid);
let mut rx = reflect.ensure_notifier(TypeId::of::<E2ESvc>());
ctx.provide(E2ESvc(1));
fiber.refresh(&ctx).await;
assert!(matches!(fiber.state(), FiberState::Active { .. }));
let epoch_before = fiber.epoch();
let dir = tempfile::tempdir().unwrap();
let agents_dir = dir.path().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let entries_file = dir.path().join("entries.json");
std::fs::write(&entries_file, "{}").unwrap();
let watched_file = agents_dir.join("test.toon");
std::fs::write(&watched_file, "v1").unwrap();
let _handle = watch_cordis_entries(
ctx.clone(),
reflect.clone(),
agents_dir.clone(),
entries_file.clone(),
TypeId::of::<E2ESvc>(),
)
.expect("watcher creation should succeed");
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::write(&watched_file, "v2").unwrap();
let notified = tokio::time::timeout(Duration::from_secs(3), rx.changed())
.await
.is_ok();
if notified {
ctx.provide(E2ESvc(2));
fiber.refresh(&ctx).await;
let epoch_after = fiber.epoch();
assert!(
notified || epoch_before != epoch_after,
"either watch channel fired or epoch changed"
);
assert_ne!(
epoch_before, epoch_after,
"epoch should change after provider version bump"
);
} else {
panic!("E2E hot-reload: watch channel did not receive signal within 3s — file-watch → ReflectService::notify chain broken");
}
drop(_handle);
}
#[tokio::test]
async fn watch_many_with_invokes_on_change() {
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("watched.toml");
std::fs::write(&file_path, "v1").unwrap();
let ctx = Context::new_root();
let reflect = ctx.provide(ReflectService::new());
let fired = Arc::new(AtomicBool::new(false));
let count = Arc::new(AtomicUsize::new(0));
let fired_cb = fired.clone();
let count_cb = count.clone();
let on_change: WatchOnChange = Arc::new(move |_ctx, _paths, _outcome| {
fired_cb.store(true, Ordering::SeqCst);
count_cb.fetch_add(1, Ordering::SeqCst);
});
let _handle = watch_many_with(
ctx.clone(),
reflect.clone(),
vec![file_path.clone()],
TypeId::of::<ReflectService>(),
on_change,
)
.expect("watch_many_with should succeed for existing temp file");
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::write(&file_path, "v2").unwrap();
let notified = tokio::time::timeout(Duration::from_secs(3), async {
loop {
if fired.load(Ordering::SeqCst) {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.is_ok();
assert!(
notified,
"on_change did not fire within 3s (500 ms defer-not-drop settle window)"
);
assert!(
count.load(Ordering::SeqCst) >= 1,
"on_change should run at least once"
);
drop(_handle);
}
#[tokio::test]
async fn rapid_successive_events_all_apply() {
use parking_lot::Mutex;
let dir = tempfile::tempdir().unwrap();
let file_a = dir.path().join("a.toml");
let file_b = dir.path().join("b.toml");
std::fs::write(&file_a, "a-v1").unwrap();
std::fs::write(&file_b, "b-v1").unwrap();
let ctx = Context::new_root();
let reflect = ctx.provide(ReflectService::new());
let calls = Arc::new(Mutex::new(0usize));
let seen = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
let calls_cb = calls.clone();
let seen_cb = seen.clone();
let on_change: WatchOnChange = Arc::new(move |_ctx, paths, _outcome| {
*calls_cb.lock() += 1;
seen_cb.lock().extend(paths.iter().cloned());
});
let _handle = watch_many_with(
ctx,
reflect,
vec![file_a.clone(), file_b.clone()],
TypeId::of::<ReflectService>(),
on_change,
)
.expect("watch_many_with should succeed for existing temp files");
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::write(&file_a, "a-v2").unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if seen.lock().iter().any(|p| p == &file_a) {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("phase 1: first change must be applied");
std::fs::write(&file_a, "a-v3").unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
std::fs::write(&file_b, "b-final").unwrap();
let b_applied = tokio::time::timeout(Duration::from_secs(3), async {
loop {
if seen.lock().iter().any(|p| p == &file_b) {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.is_ok();
let seen_paths = seen.lock().clone();
assert!(
b_applied,
"in-window event for file B must be deferred, not dropped; \
seen = {seen_paths:?}"
);
assert!(*calls.lock() >= 1, "on_change should run at least once");
drop(_handle);
}
#[cfg(feature = "hmr")]
#[tokio::test]
async fn watch_many_applies_dylib_from_watched_path() {
let so_src = compile_test_plugin();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join(so_src.file_name().unwrap());
let ctx = Context::new_root();
let reflect = ctx.provide(ReflectService::new());
let _handle = watch_many(
ctx.clone(),
reflect.clone(),
vec![dir.path().to_path_buf()],
TypeId::of::<ReflectService>(),
)
.expect("watch_many should succeed for existing temp dir");
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::copy(&so_src, &dest).expect("copy compiled dylib into watched dir");
let loaded = tokio::time::timeout(Duration::from_secs(8), async {
loop {
if ctx
.get::<crate::hmr::HmrRegistry>()
.map(|r| r.len())
.unwrap_or(0)
>= 1
{
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await
.is_ok();
if !loaded {
crate::hmr::apply_plugin_so_if_dylib(&ctx, &dest)
.expect("fallback apply_plugin_so_if_dylib");
}
assert!(
ctx.get::<crate::hmr::HmrRegistry>()
.map(|r| r.len())
.unwrap_or(0)
>= 1,
"HmrRegistry should retain at least one loaded dylib"
);
drop(_handle);
}
#[cfg(feature = "hmr")]
fn compile_test_plugin() -> std::path::PathBuf {
let dir = tempfile::tempdir().expect("tempdir");
let src = dir.path().join("plugin.rs");
let src_text = format!(
r#"
#[unsafe(no_mangle)]
pub static CORDIS_FP: &[u8] = b"{}\0";
#[unsafe(no_mangle)]
pub extern "C" fn cordis_plugin_fingerprint() -> *const std::os::raw::c_char {{
CORDIS_FP.as_ptr() as *const _
}}
#[unsafe(no_mangle)]
pub extern "C" fn cordis_plugin_apply(_ctx: *const std::ffi::c_void) -> i32 {{
0
}}
"#,
crate::hmr::fingerprint()
);
std::fs::write(&src, src_text).expect("write plugin source");
let so = dir.path().join(lib_name("cordis_watch_plugin"));
let status = std::process::Command::new("rustc")
.args(["--edition", "2024", "--crate-type", "cdylib", "-o"])
.arg(&so)
.arg(&src)
.status()
.expect("spawn rustc");
assert!(status.success(), "rustc cdylib failed: {status}");
let so_owned = so.clone();
std::mem::forget(dir);
so_owned
}
#[cfg(feature = "hmr")]
fn lib_name(stem: &str) -> String {
if cfg!(target_os = "windows") {
format!("{stem}.dll")
} else if cfg!(target_os = "macos") {
format!("lib{stem}.dylib")
} else {
format!("lib{stem}.so")
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn watcher_no_change_short_circuit() {
use parking_lot::Mutex;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("entries.toml");
std::fs::write(&file_path, "v1").unwrap();
let ctx = Context::new_root();
let reflect = ctx.provide(ReflectService::new());
let seen = Arc::new(Mutex::new(Vec::<PathBuf>::new()));
let seen_cb = seen.clone();
let on_change: WatchOnChange =
Arc::new(move |_ctx, paths, _outcome| seen_cb.lock().extend(paths.iter().cloned()));
let _handle = watch_many_with(
ctx,
reflect,
vec![file_path.clone()],
TypeId::of::<ReflectService>(),
on_change,
)
.expect("watcher should start");
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::write(&file_path, "v1").unwrap();
let seeded = tokio::time::timeout(Duration::from_secs(3), async {
loop {
if !seen.lock().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.is_ok();
assert!(seeded, "seeding change must reach the callback");
let count_after_seed = seen.lock().len();
std::fs::write(&file_path, "v1").unwrap();
let quiet = tokio::time::timeout(Duration::from_millis(1500), async {
loop {
if seen.lock().len() > count_after_seed {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.is_err();
assert!(
quiet,
"identical rewrite must short-circuit (callback fired for {seen:?})"
);
std::fs::write(&file_path, "v2").unwrap();
let fired = tokio::time::timeout(Duration::from_secs(3), async {
loop {
if seen.lock().len() > count_after_seed {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.is_ok();
assert!(
fired,
"real content change must reach the callback; seen = {seen:?}"
);
drop(_handle);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn watcher_classifies_applied_and_failed() {
use crate::loader::{CurrentEntries, EntryTree};
use crate::stamp::ReloadOutcome;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("cordis-entries.toml");
std::fs::write(&file_path, "").unwrap();
let ctx = Context::new_root();
let reflect = ctx.provide(ReflectService::new());
crate::LoaderJournal::provide_new(&ctx);
ctx.provide(crate::RegistryService::new());
let registry = ctx.provide(crate::PluginRegistry::new());
#[derive(Debug)]
struct Probe(u64);
impl crate::Service for Probe {}
registry.register(
"ProbeService",
Arc::new(|ctx, _cfg| {
let fut = ctx.plugin(Probe(1));
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
}),
);
ctx.provide_arc(Arc::new(CurrentEntries {
tree: Arc::new(std::sync::Mutex::new(EntryTree(vec![]))),
path: file_path.clone(),
}));
let outcomes = Arc::new(parking_lot::Mutex::new(Vec::<ReloadOutcome>::new()));
let outcomes_cb = outcomes.clone();
let on_change: WatchOnChange =
Arc::new(move |_ctx, _paths, outcome| outcomes_cb.lock().push(outcome.clone()));
let _handle = watch_many_with(
ctx.clone(),
reflect,
vec![file_path.clone()],
TypeId::of::<crate::ReflectService>(),
on_change,
)
.expect("watcher should start");
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::write(
&file_path,
"[[entry]]\nid = \"probe\"\nplugin = \"ProbeService\"\ndisabled = false\n\n[entry.config]\n",
)
.unwrap();
let applied = tokio::time::timeout(Duration::from_secs(4), async {
loop {
let got = outcomes.lock().iter().any(
|o| matches!(o, ReloadOutcome::Applied { actions } if !actions.is_empty()),
);
if got {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.is_ok();
assert!(
applied,
"good content must classify Applied; got {:?}",
outcomes.lock()
);
std::fs::write(&file_path, "[[entry\nid = broken").unwrap();
let failed = tokio::time::timeout(Duration::from_secs(4), async {
loop {
let got = outcomes.lock().iter().any(|o| match o {
ReloadOutcome::Failed { error } => !error.is_empty(),
_ => false,
});
if got {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.is_ok();
assert!(
failed,
"malformed TOML must classify Failed; got {:?}",
outcomes.lock()
);
drop(_handle);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn watcher_module_graph_fan_out_reloads_dependents() {
use crate::module_graph::{ModuleGraph, ModuleReload};
use crate::service::CordisError;
struct RecordingReload {
ops: parking_lot::Mutex<Vec<String>>,
}
impl ModuleReload for RecordingReload {
fn reload(&self, _ctx: &Arc<Context>, plugin: &str) -> Result<(), CordisError> {
self.ops.lock().push(format!("reload:{plugin}"));
Ok(())
}
fn rollback(&self, _ctx: &Arc<Context>, plugin: &str) -> Result<(), CordisError> {
self.ops.lock().push(format!("rollback:{plugin}"));
Ok(())
}
}
let dir = tempfile::tempdir().unwrap();
let agents_dir = dir.path().join("agents");
std::fs::create_dir_all(&agents_dir).unwrap();
let mod_a = agents_dir.join("mod_a.toon");
std::fs::write(&mod_a, "v1").unwrap();
let ctx = Context::new_root();
let reflect = ctx.provide(ReflectService::new());
reflect.set_context(&ctx);
let reloader = Arc::new(RecordingReload {
ops: parking_lot::Mutex::new(Vec::new()),
});
let graph = Arc::new(ModuleGraph::with_reloader(reloader.clone()));
graph.register_module("mod_a", vec![], "plugin.a");
graph.register_module("mod_b", vec!["mod_a".into()], "plugin.b");
ctx.provide_arc(graph);
let _handle = watch_many(
ctx.clone(),
reflect,
vec![agents_dir.clone()],
TypeId::of::<crate::ReflectService>(),
)
.expect("watcher should start");
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::write(&mod_a, "v2").unwrap();
let settled = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if !reloader.ops.lock().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await
.is_ok();
let ops = reloader.ops.lock().clone();
assert!(settled, "module-graph fan-out never fired; ops={ops:?}");
assert_eq!(ops, s(&["reload:plugin.a", "reload:plugin.b"]));
drop(_handle);
}
#[tokio::test]
async fn module_graph_without_registration_is_ignored() {
use crate::module_graph::{ChangeOutcome, ModuleGraph};
let graph = ModuleGraph::new();
let ctx = Context::new_root();
let outcome = graph.change_many(&ctx, &["anything".to_string()]);
assert_eq!(outcome, ChangeOutcome::Ignored);
}
fn s(items: &[&str]) -> Vec<String> {
items.iter().map(|i| i.to_string()).collect()
}
}