oxigdal_gpu/shader_reload_native.rs
1//! Native filesystem-polling backend for shader hot-reload.
2//!
3//! The base [`crate::shader_reload::ShaderWatcher`] tracks shader sources by
4//! in-memory version counters and never touches the filesystem. This module
5//! adds a real native polling backend, [`FilesystemPoller`], that watches a set
6//! of paths on disk and reports modification / deletion / (re-)creation events
7//! by comparing recorded modification timestamps (`mtime`).
8//!
9//! The poller is intentionally dependency-free — it relies only on `std` — and
10//! is gated behind the `shader-hot-reload` feature so that embedded / WASM
11//! builds that cannot poll a filesystem do not pay for it.
12//!
13//! # Throttling
14//!
15//! [`FilesystemPoller::poll`] is throttled to the configured interval so that a
16//! tight render loop can call it every frame without hammering the filesystem.
17//! [`FilesystemPoller::force_poll`] bypasses the throttle and is primarily used
18//! for tests and for an explicit "reload now" command.
19//!
20//! # Created vs Modified
21//!
22//! `std::fs::metadata` cannot be read for a file that does not yet exist, so a
23//! path cannot be *registered* before its file is created. To still detect a
24//! file that appears later (or reappears after deletion),
25//! [`FilesystemPoller::track_expected`] records the path with a
26//! [`SystemTime::UNIX_EPOCH`] sentinel. When the file subsequently appears the
27//! poller distinguishes the two cases as follows:
28//!
29//! * stored timestamp == `UNIX_EPOCH` and the file now exists → [`PolledChangeKind::Created`]
30//! * stored timestamp is a real time and the on-disk `mtime` is newer → [`PolledChangeKind::Modified`]
31
32use std::collections::HashMap;
33use std::path::{Path, PathBuf};
34use std::time::{Duration, Instant, SystemTime};
35
36/// Default polling interval used by [`FilesystemPoller::with_default_interval`].
37const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(250);
38
39/// Classification of a change detected by [`FilesystemPoller`].
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PolledChangeKind {
42 /// The file's modification time advanced relative to the recorded value.
43 Modified,
44 /// The file is no longer accessible (deleted, moved, or permission lost).
45 Deleted,
46 /// A previously expected (or previously deleted) file has appeared.
47 Created,
48}
49
50/// A single change reported by [`FilesystemPoller::poll`] / [`FilesystemPoller::force_poll`].
51#[derive(Debug, Clone)]
52pub struct PolledChange {
53 /// The path whose state changed.
54 pub path: PathBuf,
55 /// What kind of change was observed.
56 pub kind: PolledChangeKind,
57}
58
59/// Polls a set of filesystem paths for modification-time changes.
60///
61/// Each watched path is associated with the [`SystemTime`] of its last observed
62/// modification. On every (un-throttled) poll the current `mtime` of each path
63/// is compared against the recorded value to produce [`PolledChange`] events.
64///
65/// See the [module documentation](self) for the throttling and
66/// created-vs-modified semantics.
67pub struct FilesystemPoller {
68 /// Recorded modification time per watched path.
69 mtimes: HashMap<PathBuf, SystemTime>,
70 /// Minimum delay between throttled [`Self::poll`] invocations.
71 poll_interval: Duration,
72 /// Instant of the most recent (throttled or forced) poll, if any.
73 last_poll: Option<Instant>,
74}
75
76impl FilesystemPoller {
77 /// Create a poller with an explicit throttling interval.
78 pub fn new(poll_interval: Duration) -> Self {
79 Self {
80 mtimes: HashMap::new(),
81 poll_interval,
82 last_poll: None,
83 }
84 }
85
86 /// Create a poller using the default 250 ms throttling interval.
87 pub fn with_default_interval() -> Self {
88 Self::new(DEFAULT_POLL_INTERVAL)
89 }
90
91 /// Start watching `path`, seeding the modification-time table from the
92 /// file's current `mtime`.
93 ///
94 /// # Errors
95 ///
96 /// Propagates any [`std::io::Error`] from reading the file's metadata or
97 /// modification time — most commonly [`std::io::ErrorKind::NotFound`] when
98 /// the file does not exist. To watch a not-yet-created path use
99 /// [`Self::track_expected`] instead.
100 pub fn register_path<P: AsRef<Path>>(&mut self, path: P) -> std::io::Result<()> {
101 let path_ref = path.as_ref();
102 let modified = std::fs::metadata(path_ref)?.modified()?;
103 self.mtimes.insert(path_ref.to_path_buf(), modified);
104 Ok(())
105 }
106
107 /// Track a path that may not exist yet.
108 ///
109 /// The path is recorded with a [`SystemTime::UNIX_EPOCH`] sentinel so that
110 /// when the file later appears the next poll reports it as
111 /// [`PolledChangeKind::Created`] (rather than [`PolledChangeKind::Modified`]).
112 /// While the file is still absent the sentinel suppresses any
113 /// [`PolledChangeKind::Deleted`] event — the path simply waits to appear.
114 /// This also re-arms creation detection for a path that was previously
115 /// dropped after a deletion.
116 pub fn track_expected<P: AsRef<Path>>(&mut self, path: P) {
117 self.mtimes
118 .insert(path.as_ref().to_path_buf(), SystemTime::UNIX_EPOCH);
119 }
120
121 /// Stop watching `path`.
122 ///
123 /// Returns `true` if the path was being watched, `false` otherwise.
124 pub fn deregister_path<P: AsRef<Path>>(&mut self, path: P) -> bool {
125 self.mtimes.remove(path.as_ref()).is_some()
126 }
127
128 /// Return all currently watched paths, sorted ascending for deterministic
129 /// diagnostics.
130 pub fn watched_paths(&self) -> Vec<PathBuf> {
131 let mut paths: Vec<PathBuf> = self.mtimes.keys().cloned().collect();
132 paths.sort();
133 paths
134 }
135
136 /// Number of paths currently being watched.
137 pub fn watched_count(&self) -> usize {
138 self.mtimes.len()
139 }
140
141 /// Poll for changes, honouring the configured throttling interval.
142 ///
143 /// If a previous poll occurred less than `poll_interval` ago this returns an
144 /// empty vector without touching the filesystem. Otherwise it records the
145 /// current instant and runs the full comparison.
146 pub fn poll(&mut self) -> Vec<PolledChange> {
147 if let Some(last) = self.last_poll
148 && last.elapsed() < self.poll_interval
149 {
150 return Vec::new();
151 }
152 self.last_poll = Some(Instant::now());
153 self.diff_against_disk()
154 }
155
156 /// Poll for changes immediately, ignoring the throttling interval.
157 ///
158 /// The throttle clock is still updated so that a subsequent [`Self::poll`]
159 /// is measured relative to this call.
160 pub fn force_poll(&mut self) -> Vec<PolledChange> {
161 self.last_poll = Some(Instant::now());
162 self.diff_against_disk()
163 }
164
165 /// Core comparison shared by [`Self::poll`] and [`Self::force_poll`].
166 ///
167 /// Iterates the watched paths in sorted order (deterministic output),
168 /// classifies each against its recorded `mtime`, then applies the resulting
169 /// table mutations (timestamp bumps and deletions) after iteration to avoid
170 /// borrowing conflicts.
171 fn diff_against_disk(&mut self) -> Vec<PolledChange> {
172 let mut ordered: Vec<PathBuf> = self.mtimes.keys().cloned().collect();
173 ordered.sort();
174
175 let mut changes = Vec::new();
176 let mut updates: Vec<(PathBuf, SystemTime)> = Vec::new();
177 let mut removals: Vec<PathBuf> = Vec::new();
178
179 for path in ordered {
180 let stored = match self.mtimes.get(&path) {
181 Some(stored) => *stored,
182 None => continue,
183 };
184
185 match std::fs::metadata(&path).and_then(|meta| meta.modified()) {
186 Ok(current) => {
187 if stored == SystemTime::UNIX_EPOCH {
188 // A path tracked via `track_expected` whose file now
189 // exists: report creation and adopt the real mtime.
190 changes.push(PolledChange {
191 path: path.clone(),
192 kind: PolledChangeKind::Created,
193 });
194 updates.push((path, current));
195 } else if current > stored {
196 changes.push(PolledChange {
197 path: path.clone(),
198 kind: PolledChangeKind::Modified,
199 });
200 updates.push((path, current));
201 }
202 }
203 Err(_) => {
204 // Metadata unreadable (deleted / moved / permissions).
205 // A path still carrying the `track_expected` sentinel has
206 // never actually appeared, so its absence is the expected
207 // initial state — keep waiting rather than reporting a
208 // bogus deletion. Only a path with a real recorded mtime
209 // (i.e. one that genuinely existed) yields `Deleted`, and
210 // it is then dropped from the table.
211 if stored != SystemTime::UNIX_EPOCH {
212 changes.push(PolledChange {
213 path: path.clone(),
214 kind: PolledChangeKind::Deleted,
215 });
216 removals.push(path);
217 }
218 }
219 }
220 }
221
222 for (path, modified) in updates {
223 self.mtimes.insert(path, modified);
224 }
225 for path in removals {
226 self.mtimes.remove(&path);
227 }
228
229 changes
230 }
231}
232
233/// Read a WGSL shader source from disk as UTF-8 text, stripping a leading
234/// byte-order mark (`U+FEFF`) if one is present.
235///
236/// Some editors prepend a UTF-8 BOM; left in place it would appear inside the
237/// first token of the shader and break compilation, so it is removed here.
238///
239/// # Errors
240///
241/// Propagates any [`std::io::Error`] from reading the file (e.g. the file does
242/// not exist or is not valid UTF-8).
243pub fn read_shader_source<P: AsRef<Path>>(path: P) -> std::io::Result<String> {
244 let contents = std::fs::read_to_string(path)?;
245 match contents.strip_prefix('\u{FEFF}') {
246 Some(stripped) => Ok(stripped.to_owned()),
247 None => Ok(contents),
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn test_default_interval_constant() {
257 let poller = FilesystemPoller::with_default_interval();
258 assert_eq!(poller.poll_interval, DEFAULT_POLL_INTERVAL);
259 assert!(poller.last_poll.is_none());
260 assert_eq!(poller.watched_count(), 0);
261 }
262
263 #[test]
264 fn test_strip_bom_via_str() {
265 // Sanity check of the BOM-stripping logic without filesystem access.
266 let with_bom = "\u{FEFF}fn main() {}";
267 assert_eq!(with_bom.strip_prefix('\u{FEFF}'), Some("fn main() {}"));
268 }
269}