Skip to main content

khora_io/asset/
watcher.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Filesystem watcher for hot-reload.
16//!
17//! Wraps [`notify::RecommendedWatcher`] (cross-platform — inotify on Linux,
18//! FSEvents on macOS, ReadDirectoryChangesW on Windows) and translates raw
19//! events into [`AssetChangeEvent`]s with pre-computed UUIDs.
20//!
21//! The editor's frame loop calls [`AssetWatcher::poll`] each frame to drain
22//! pending events and feed them into `AssetService::invalidate` /
23//! `reindex` — see the editor's `before_agents` hot-reload pump.
24//!
25//! # Threading
26//!
27//! `notify` v6 spawns its own internal backend thread for the OS-level event
28//! source (this is unavoidable — that's how kernel APIs deliver events). We
29//! never call `std::thread::spawn` from this crate, so the workspace
30//! convention "no `std::thread::spawn` in user code" is respected. The
31//! crossbeam channel is bounded only by the internal handler closure; the
32//! consumer side is non-blocking via [`AssetWatcher::poll`].
33
34use anyhow::{Context, Result};
35use crossbeam_channel::{Receiver, Sender};
36use khora_core::asset::AssetUUID;
37use notify::{recommended_watcher, RecommendedWatcher, RecursiveMode, Watcher};
38use std::{
39    collections::HashSet,
40    path::{Path, PathBuf},
41};
42
43use super::index_builder::should_skip_file;
44
45/// What kind of change happened to an asset on disk.
46///
47/// Renames are decomposed into `Removed` + `Created` to keep consumer code
48/// simple — handle two events instead of carrying a `from`/`to` pair through
49/// the pipeline.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum AssetChangeKind {
52    /// A new asset file appeared.
53    Created,
54    /// An existing asset file's bytes (or attributes) changed.
55    Modified,
56    /// An asset file was deleted.
57    Removed,
58}
59
60/// One filesystem change against an asset under the watched root.
61#[derive(Debug, Clone)]
62pub struct AssetChangeEvent {
63    /// What happened.
64    pub kind: AssetChangeKind,
65    /// Path relative to the watched assets root, forward-slash separated for
66    /// cross-platform UUID stability.
67    pub rel_path: String,
68    /// UUID derived from `rel_path` via [`AssetUUID::new_v5`]. May not yet
69    /// (or no longer) exist in the [`crate::vfs::VirtualFileSystem`] — the
70    /// consumer reconciles by reindex + invalidate as appropriate.
71    pub uuid: AssetUUID,
72}
73
74/// Drains filesystem-change events under a project's `assets/` directory.
75///
76/// Holds `notify`'s `RecommendedWatcher` alive — drop the [`AssetWatcher`]
77/// to stop watching.
78pub struct AssetWatcher {
79    // Kept alive for its Drop side-effect (releases the OS handle).
80    _watcher: RecommendedWatcher,
81    receiver: Receiver<AssetChangeEvent>,
82    assets_root: PathBuf,
83}
84
85impl AssetWatcher {
86    /// Starts watching `assets_root` recursively. Future writes / creates /
87    /// removes under that tree produce events drained via [`Self::poll`].
88    ///
89    /// Returns an error if `notify` fails to construct a recommended watcher
90    /// or to register the path (e.g. the path doesn't exist or the OS denies
91    /// the watch). The editor passes a path it has just `create_dir_all`'d,
92    /// so this should be reliable in practice.
93    pub fn new(assets_root: impl Into<PathBuf>) -> Result<Self> {
94        let assets_root = assets_root.into();
95        let (tx, rx): (Sender<AssetChangeEvent>, Receiver<AssetChangeEvent>) =
96            crossbeam_channel::unbounded();
97        let root_for_handler = assets_root.clone();
98
99        let mut watcher = recommended_watcher(move |res: notify::Result<notify::Event>| {
100            let event = match res {
101                Ok(e) => e,
102                Err(e) => {
103                    log::warn!("notify error: {e}");
104                    return;
105                }
106            };
107            for path in &event.paths {
108                if let Some(change) = translate_event(&event.kind, path, &root_for_handler) {
109                    let _ = tx.send(change);
110                }
111            }
112        })
113        .context("Failed to create filesystem watcher")?;
114
115        watcher
116            .watch(&assets_root, RecursiveMode::Recursive)
117            .with_context(|| format!("Failed to watch {}", assets_root.display()))?;
118
119        Ok(Self {
120            _watcher: watcher,
121            receiver: rx,
122            assets_root,
123        })
124    }
125
126    /// Returns the watched assets root.
127    pub fn assets_root(&self) -> &Path {
128        &self.assets_root
129    }
130
131    /// Drains all pending events without blocking.
132    ///
133    /// Coalesces repeated `Modified` events on the same path within the same
134    /// drain — `notify` v6 fires multiple Modified for one save on Windows.
135    /// The order of distinct events is preserved for events on different
136    /// paths.
137    pub fn poll(&self) -> Vec<AssetChangeEvent> {
138        let mut out: Vec<AssetChangeEvent> = Vec::new();
139        let mut seen_modified: HashSet<String> = HashSet::new();
140        while let Ok(event) = self.receiver.try_recv() {
141            if matches!(event.kind, AssetChangeKind::Modified)
142                && !seen_modified.insert(event.rel_path.clone())
143            {
144                // Already emitted a Modified for this path in this drain.
145                continue;
146            }
147            out.push(event);
148        }
149        out
150    }
151}
152
153/// Maps a raw `notify::EventKind` + absolute path to one of our
154/// [`AssetChangeEvent`]s. Returns `None` if the path isn't a recognized
155/// asset (filtered via `asset_type_for_extension`) or if the event kind is
156/// uninteresting (Access, Other, Any).
157fn translate_event(
158    kind: &notify::EventKind,
159    abs: &Path,
160    assets_root: &Path,
161) -> Option<AssetChangeEvent> {
162    use notify::event::{ModifyKind, RenameMode};
163    let our_kind = match kind {
164        notify::EventKind::Create(_) => AssetChangeKind::Created,
165        notify::EventKind::Remove(_) => AssetChangeKind::Removed,
166        // ModifyKind::Name(...) = renames. notify reports them as paired
167        // Remove/Create on Linux but as Modify(Name(...)) on macOS/Windows.
168        // We simplify: rename = Removed-then-Created (or vice versa) by
169        // treating Modify(Name) as Modified — the consumer's reindex pass
170        // will pick up the new path on the subsequent Create event anyway.
171        notify::EventKind::Modify(ModifyKind::Name(RenameMode::From)) => AssetChangeKind::Removed,
172        notify::EventKind::Modify(ModifyKind::Name(RenameMode::To)) => AssetChangeKind::Created,
173        notify::EventKind::Modify(_) => AssetChangeKind::Modified,
174        // Access / Any / Other: not interesting for hot-reload.
175        _ => return None,
176    };
177
178    // Drop OS scratch files / editor swap files. Anything else is a
179    // legitimate asset under `assets/` and should fire a hot-reload
180    // event — the VFS now tracks every extension so the previous
181    // canonical-allowlist filter no longer makes sense here.
182    let file_name = abs.file_name().and_then(|n| n.to_str())?;
183    if should_skip_file(file_name) {
184        return None;
185    }
186
187    let rel = abs.strip_prefix(assets_root).ok()?;
188    let rel_str = rel
189        .components()
190        .map(|c| c.as_os_str().to_string_lossy().into_owned())
191        .collect::<Vec<_>>()
192        .join("/");
193
194    let uuid = AssetUUID::new_v5(&rel_str);
195    Some(AssetChangeEvent {
196        kind: our_kind,
197        rel_path: rel_str,
198        uuid,
199    })
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::{fs, thread::sleep, time::Duration};
206    use tempfile::tempdir;
207
208    /// notify is timing-dependent on every backend; this test allows up to
209    /// `MAX_WAIT` for the watcher's own backend thread to deliver an event.
210    /// Marked `#[ignore]` so CI can opt-in — it's flaky on heavily-loaded
211    /// runners.
212    const MAX_WAIT: Duration = Duration::from_secs(2);
213
214    #[test]
215    #[ignore = "filesystem-watcher tests are timing-dependent; run manually"]
216    fn watcher_emits_event_on_create() {
217        let dir = tempdir().unwrap();
218        let watcher = AssetWatcher::new(dir.path()).unwrap();
219
220        // Give the watcher backend a moment to arm.
221        sleep(Duration::from_millis(100));
222
223        fs::create_dir_all(dir.path().join("textures")).unwrap();
224        fs::write(dir.path().join("textures").join("foo.png"), b"PNG").unwrap();
225
226        let mut events = Vec::new();
227        let deadline = std::time::Instant::now() + MAX_WAIT;
228        while std::time::Instant::now() < deadline {
229            events.extend(watcher.poll());
230            if events.iter().any(|e| e.rel_path == "textures/foo.png") {
231                break;
232            }
233            sleep(Duration::from_millis(50));
234        }
235
236        assert!(
237            events.iter().any(|e| e.rel_path == "textures/foo.png"),
238            "expected at least one event for textures/foo.png; got {:?}",
239            events
240        );
241    }
242}