lattice_plugin_loader/watch.rs
1//! PL8.D.4 — auto-reload the user's `init.rs` config when its compiled artifact
2//! changes on disk.
3//!
4//! The edit→rebuild→reload loop shouldn't need a manual `:reload-config`. This
5//! watches `<config>/lattice/init/` with the `notify` crate and calls
6//! [`PluginLoader::sync_init`] (load-or-reload) whenever the directory changes —
7//! so after you `cargo build` a new `init.wasm`, the editor picks it up.
8//!
9//! **Why the artifact, not the source.** `init.rs` is Rust *source*; the editor
10//! loads the compiled `init.wasm`. Saving the source doesn't change the artifact,
11//! so the watch is on the directory holding the `.wasm` and fires on the write a
12//! build produces. To compile source → artifact *without* an external `cargo`
13//! step, use [`PluginLoader::reload_config`] (the `:reload-config` command) or
14//! the plugins view's `b` on the `init` row — both recompile in place, then
15//! reload; a full source-watching auto-rebuild loop is future work.
16//!
17//! **Debounce.** A build rewrites the file in a burst (truncate, write, rename);
18//! the watcher coalesces events, waiting for [`SETTLE`] of quiet before it
19//! reloads once, so a single rebuild triggers a single reload.
20//!
21//! **Broken build heals.** `sync_init` reloads when `init` is loaded and loads
22//! when it isn't; a rebuild that produces a bad component leaves `init` unloaded
23//! (logged), and the next good build's write fires this again and loads it. No
24//! restart needed either way.
25
26use std::path::PathBuf;
27use std::time::Duration;
28
29use tokio::runtime::Handle;
30use tokio::sync::mpsc;
31
32use lattice_plugin_host::TrustTier;
33
34use crate::PluginLoaderHandle;
35
36/// Quiet window after the last fs event before reloading — coalesces a build's
37/// event burst into one reload.
38const SETTLE: Duration = Duration::from_millis(300);
39
40/// Spawn the init-artifact auto-reload watcher on `runtime`. Watches `init_dir`
41/// (non-recursively) and `sync_init`s the loader on every settled change. A
42/// watcher that can't be created / can't watch the dir is a logged warning that
43/// disables auto-reload (manual `:reload-config` still works), never a failure.
44/// No-op if `init_dir` doesn't exist (nothing to watch until the user creates a
45/// config; a restart picks it up).
46pub fn spawn_init_watcher(loader: PluginLoaderHandle, init_dir: PathBuf, runtime: &Handle) {
47 if !init_dir.is_dir() {
48 return;
49 }
50 runtime.spawn(async move {
51 // `notify` invokes its callback on its own OS thread; forward a unit
52 // "something changed" tick to this async task over an mpsc. Content /
53 // ordering don't matter — the task debounces + reloads from disk.
54 let (tx, mut rx) = mpsc::unbounded_channel::<()>();
55 let mut watcher = match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
56 if let Ok(event) = res {
57 // Create / modify / rename land a new artifact; access events
58 // (reads) don't and are ignored.
59 if matches!(
60 event.kind,
61 notify::EventKind::Create(_)
62 | notify::EventKind::Modify(_)
63 | notify::EventKind::Remove(_)
64 ) {
65 let _ = tx.send(());
66 }
67 }
68 }) {
69 Ok(w) => w,
70 Err(err) => {
71 tracing::warn!(error = %err, "init auto-reload disabled: cannot create fs watcher");
72 return;
73 }
74 };
75 if let Err(err) = notify::Watcher::watch(
76 &mut watcher,
77 &init_dir,
78 notify::RecursiveMode::NonRecursive,
79 ) {
80 tracing::warn!(
81 dir = %init_dir.display(),
82 error = %err,
83 "init auto-reload disabled: cannot watch the init dir"
84 );
85 return;
86 }
87
88 // The watcher must outlive the loop (dropping it stops watching); it is
89 // owned here for the task's lifetime.
90 tracing::debug!(dir = %init_dir.display(), "watching init config for changes");
91 while rx.recv().await.is_some() {
92 // Settle: keep draining while more events arrive within `SETTLE`, so
93 // one rebuild's event burst collapses to one reload. Breaks on quiet
94 // (`Err(Elapsed)`) or channel close (`Ok(None)`).
95 while let Ok(Some(())) = tokio::time::timeout(SETTLE, rx.recv()).await {
96 // more events arrived within the window — keep waiting
97 }
98 tracing::info!(dir = %init_dir.display(), "init config changed; reloading");
99 match loader.sync_init(&init_dir, TrustTier::Bundled).await {
100 Ok(id) => tracing::info!(id = id.0, "init config auto-reloaded"),
101 Err(err) => tracing::warn!(
102 error = %err,
103 "init config auto-reload failed (fix the build and rebuild — the next good build reloads it)"
104 ),
105 }
106 }
107 });
108}