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: ¬ify::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}