Skip to main content

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}