Skip to main content

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}