Skip to main content

qframe/storage/
folder_watch.rs

1//! Folder changes from the operating system's own events, without polling.
2//!
3//! Rereading a folder on a timer costs work every tick and still shows a change late. The kernel
4//! already knows the moment an entry is created, removed or renamed; asking it to say so costs
5//! nothing while nothing happens. On Linux that is inotify, reached through `rustix` without
6//! `unsafe`.
7
8use std::ffi::OsString;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13
14/// How long a batch keeps gathering after its first event. A `git checkout` touches thousands of
15/// files in a burst; answering each would redraw a tree thousands of times, while a tenth of a
16/// second is still quicker than anyone notices.
17const GATHER: Duration = Duration::from_millis(100);
18
19/// What happened in a watched folder; see [`FolderChange`].
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub enum FolderChangeKind {
22    /// An entry appeared: created here, or moved in from a folder this watch does not see.
23    Created,
24    /// An entry disappeared: removed, or moved out to a folder this watch does not see.
25    Removed,
26    /// An entry was renamed inside the folder; [`FolderChange::name`] is its new name.
27    Renamed {
28        /// The name the entry had before.
29        from: OsString,
30    },
31    /// An entry's content or attributes (permissions, times) changed.
32    Modified,
33    /// The watched folder itself was removed, moved away or unmounted. It is reported once and no
34    /// longer watched; watch it again under its new path if it still matters.
35    Gone,
36    /// The system dropped events because they came faster than they were read. Anything may have
37    /// changed in this folder, so read it again.
38    Overflow,
39}
40
41/// One change in a watched folder, as [`FolderChanges::next`] reports it.
42///
43/// A change carries the name of the entry it concerns, so an application that shows a single
44/// file can tell whether that file changed without rereading anything. Most applications need
45/// less: they read [`folder`](Self::folder) again, once per batch, whatever the names are.
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub struct FolderChange {
48    /// The watched folder, as it was given to [`FolderWatch::watch`].
49    pub folder: PathBuf,
50    /// The entry inside it that changed; `None` when the change concerns the folder as a whole
51    /// ([`FolderChangeKind::Gone`] and [`FolderChangeKind::Overflow`]).
52    pub name: Option<OsString>,
53    /// What happened.
54    pub kind: FolderChangeKind,
55}
56
57/// Watches folders for changes the operating system reports, and never polls.
58///
59/// The watch is not recursive: a folder's own entries are watched, not what happens deeper
60/// down. That matches a file tree, which only has to know about the folders it shows open.
61///
62/// The watch is owned by the application, which adds and removes folders as the user opens and
63/// closes them. The waiting happens elsewhere: [`changes`](Self::changes) gives a handle whose
64/// [`FolderChanges::next`] blocks, sleeping in the kernel, until something changes. Run it in a
65/// [`Command::perform`](crate::runtime::Command::perform) and start it again when its message
66/// arrives. Dropping the watch wakes a waiting `next`, which then answers an empty list.
67///
68/// ```no_run
69/// # use qframe::storage::FolderWatch;
70/// let mut watch = FolderWatch::new()?;
71/// watch.watch(std::path::Path::new("/home/me/project"))?;
72/// let changes = watch.changes();
73/// // Inside a `Command::perform`:
74/// for change in changes.next() {
75///     println!("{} changed: {:?}", change.folder.display(), change.kind);
76/// }
77/// # Ok::<(), std::io::Error>(())
78/// ```
79///
80/// Only Linux has this watch so far, through inotify. On every other platform
81/// [`FolderWatch::new`] returns an error of kind [`io::ErrorKind::Unsupported`], and the
82/// application keeps rereading at the moments it chooses (after its own changes, on return to a
83/// screen, on a "refresh" key). macOS and Windows have their own event sources, but this framework
84/// reaches none of them without `unsafe` or a large dependency.
85#[derive(Debug)]
86pub struct FolderWatch {
87    source: Arc<platform::Source>,
88}
89
90/// The waiting side of a [`FolderWatch`]; see [`FolderChanges::next`].
91///
92/// Cheap to clone and free to move to another thread, so each
93/// [`Command::perform`](crate::runtime::Command::perform) can take its own.
94#[derive(Debug, Clone)]
95pub struct FolderChanges {
96    source: Arc<platform::Source>,
97}
98
99impl FolderWatch {
100    /// A watch with no folders yet.
101    ///
102    /// # Errors
103    ///
104    /// Returns the system's error when no watch can be made (too many open watches for this user,
105    /// for example), and [`io::ErrorKind::Unsupported`] on a platform other than Linux.
106    pub fn new() -> io::Result<Self> {
107        Ok(Self { source: Arc::new(platform::Source::new()?) })
108    }
109
110    /// Starts watching the entries of `folder`. Watching a folder that is already watched does
111    /// nothing.
112    ///
113    /// # Errors
114    ///
115    /// Returns the error when `folder` is missing or is not a folder, and an error of kind
116    /// [`io::ErrorKind::QuotaExceeded`] when the system's limit on watches
117    /// (`fs.inotify.max_user_watches` on Linux) is reached. Either way the application can go on
118    /// without live changes for that folder.
119    pub fn watch(&mut self, folder: &Path) -> io::Result<()> {
120        self.source.watch(folder)
121    }
122
123    /// Stops watching `folder`. A folder that is not watched, or that is [`FolderChangeKind::Gone`],
124    /// is left alone.
125    pub fn unwatch(&mut self, folder: &Path) {
126        self.source.unwatch(folder);
127    }
128
129    /// The handle that waits for this watch's changes.
130    #[must_use]
131    pub fn changes(&self) -> FolderChanges {
132        FolderChanges { source: Arc::clone(&self.source) }
133    }
134}
135
136impl Drop for FolderWatch {
137    fn drop(&mut self) {
138        // A waiter must not sleep forever on a watch nobody can add to or read from any more.
139        self.source.close();
140    }
141}
142
143impl FolderChanges {
144    /// Blocks until something changes in a watched folder, then returns everything that changed
145    /// within the next tenth of a second, oldest first and each change once. A burst of
146    /// thousands of changes therefore arrives as a few batches, not as thousands of answers.
147    ///
148    /// While nothing changes the thread sleeps in the kernel and costs no processor time. When the
149    /// [`FolderWatch`] is dropped, a waiting `next` wakes and returns an empty list, and so does
150    /// every later call: an empty answer means "stop waiting". Call it inside
151    /// [`Command::perform`](crate::runtime::Command::perform), never in `update` or `view`.
152    ///
153    /// A rename inside one folder is one [`FolderChangeKind::Renamed`]. An entry moved from one
154    /// watched folder to another is [`FolderChangeKind::Removed`] in the first and
155    /// [`FolderChangeKind::Created`] in the second, as it is when only one side is watched.
156    #[must_use]
157    pub fn next(&self) -> Vec<FolderChange> {
158        self.source.next(GATHER)
159    }
160}
161
162/// Collects one batch of changes: renames paired, repeats dropped, order kept.
163#[derive(Debug, Default)]
164struct Batch {
165    changes: Vec<FolderChange>,
166    /// Where each half-seen rename stands in `changes`, by the cookie that pairs its two halves.
167    moved_from: Vec<(u32, usize)>,
168}
169
170impl Batch {
171    fn is_empty(&self) -> bool {
172        self.changes.is_empty()
173    }
174
175    fn push(&mut self, folder: &Path, name: Option<OsString>, kind: FolderChangeKind) {
176        self.changes.push(FolderChange { folder: folder.to_path_buf(), name, kind });
177    }
178
179    /// The first half of a move: the entry left `folder`. It stays a removal unless the second
180    /// half arrives in the same folder.
181    fn moved_from(&mut self, cookie: u32, folder: &Path, name: OsString) {
182        self.moved_from.push((cookie, self.changes.len()));
183        self.push(folder, Some(name), FolderChangeKind::Removed);
184    }
185
186    /// The second half of a move: the entry arrived in `folder` under `name`.
187    fn moved_to(&mut self, cookie: u32, folder: &Path, name: OsString) {
188        let first = self.moved_from.iter().position(|(seen, _)| *seen == cookie);
189        if let Some(position) = first {
190            let (_, index) = self.moved_from.remove(position);
191            let earlier = &mut self.changes[index];
192            if earlier.folder == folder {
193                let from = earlier.name.take().unwrap_or_default();
194                earlier.name = Some(name);
195                earlier.kind = FolderChangeKind::Renamed { from };
196                return;
197            }
198        }
199        self.push(folder, Some(name), FolderChangeKind::Created);
200    }
201
202    /// The batch in the order it happened, each change once. A repeated change keeps its last
203    /// place, so "created, removed, created again" ends as the entry being there.
204    fn finish(self) -> Vec<FolderChange> {
205        let mut seen = std::collections::HashSet::new();
206        let mut kept: Vec<FolderChange> =
207            self.changes.into_iter().rev().filter(|change| seen.insert(change.clone())).collect();
208        kept.reverse();
209        kept
210    }
211}
212
213#[cfg(target_os = "linux")]
214mod platform {
215    use std::collections::HashMap;
216    use std::ffi::{CStr, OsStr, OsString};
217    use std::io;
218    use std::mem::MaybeUninit;
219    use std::os::fd::OwnedFd;
220    use std::os::unix::ffi::OsStrExt;
221    use std::path::{Path, PathBuf};
222    use std::sync::atomic::{AtomicBool, Ordering};
223    use std::sync::{Mutex, MutexGuard, PoisonError};
224    use std::time::{Duration, Instant};
225
226    use rustix::event::{EventfdFlags, PollFd, PollFlags, Timespec, eventfd, poll};
227    use rustix::fs::inotify::{self, CreateFlags, ReadFlags, WatchFlags};
228    use rustix::io::Errno;
229
230    use super::{Batch, FolderChange, FolderChangeKind};
231
232    /// The inotify instance, the folders it watches, and the bell that ends a wait.
233    #[derive(Debug)]
234    pub(super) struct Source {
235        inotify: OwnedFd,
236        /// Written once when the watch is dropped, and never read: staying readable is what
237        /// wakes every waiter, the present one and any later one.
238        bell: OwnedFd,
239        closed: AtomicBool,
240        folders: Mutex<Folders>,
241        /// Held by the one thread reading at a time, so two waiters never split a batch.
242        buffer: Mutex<Vec<MaybeUninit<u8>>>,
243    }
244
245    /// Watched folders by the descriptor inotify gave them, and the way back.
246    #[derive(Debug, Default)]
247    struct Folders {
248        by_descriptor: HashMap<i32, PathBuf>,
249        by_path: HashMap<PathBuf, i32>,
250    }
251
252    impl Folders {
253        fn forget(&mut self, descriptor: i32) -> Option<PathBuf> {
254            let path = self.by_descriptor.remove(&descriptor)?;
255            self.by_path.remove(&path);
256            Some(path)
257        }
258    }
259
260    fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
261        // A panic on another thread leaves the map and the buffer usable; the watch goes on.
262        mutex.lock().unwrap_or_else(PoisonError::into_inner)
263    }
264
265    /// Room for a few hundred events per read; the kernel queues the rest.
266    const BUFFER: usize = 64 * 1024;
267
268    impl Source {
269        pub(super) fn new() -> io::Result<Self> {
270            // Close-on-exec, so a child process never inherits the watch; non-blocking, because
271            // the waiting is done by `poll`, which can also hear the bell.
272            let inotify = inotify::init(CreateFlags::CLOEXEC | CreateFlags::NONBLOCK)?;
273            let bell = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?;
274            Ok(Self {
275                inotify,
276                bell,
277                closed: AtomicBool::new(false),
278                folders: Mutex::new(Folders::default()),
279                buffer: Mutex::new(vec![MaybeUninit::uninit(); BUFFER]),
280            })
281        }
282
283        pub(super) fn watch(&self, folder: &Path) -> io::Result<()> {
284            let mut folders = lock(&self.folders);
285            if folders.by_path.contains_key(folder) {
286                return Ok(());
287            }
288            let flags = WatchFlags::CREATE
289                | WatchFlags::DELETE
290                | WatchFlags::MOVED_FROM
291                | WatchFlags::MOVED_TO
292                | WatchFlags::MODIFY
293                | WatchFlags::ATTRIB
294                | WatchFlags::DELETE_SELF
295                | WatchFlags::MOVE_SELF
296                | WatchFlags::ONLYDIR;
297            let descriptor = inotify::add_watch(&self.inotify, folder, flags).map_err(|errno| {
298                if errno == Errno::NOSPC {
299                    // The kernel says "no space on device", which reads like a full disk.
300                    io::Error::new(
301                        io::ErrorKind::QuotaExceeded,
302                        "the limit on folder watches (fs.inotify.max_user_watches) is reached",
303                    )
304                } else {
305                    io::Error::from(errno)
306                }
307            })?;
308            // Two paths to one folder share a descriptor; the later path is the one reported.
309            if let Some(earlier) = folders.by_descriptor.insert(descriptor, folder.to_path_buf()) {
310                folders.by_path.remove(&earlier);
311            }
312            folders.by_path.insert(folder.to_path_buf(), descriptor);
313            Ok(())
314        }
315
316        pub(super) fn unwatch(&self, folder: &Path) {
317            let mut folders = lock(&self.folders);
318            if let Some(descriptor) = folders.by_path.get(folder).copied() {
319                folders.forget(descriptor);
320                // Events already queued for it are dropped when read: nobody knows the descriptor.
321                let _ = inotify::remove_watch(&self.inotify, descriptor);
322            }
323        }
324
325        pub(super) fn close(&self) {
326            self.closed.store(true, Ordering::SeqCst);
327            // The counter only grows, so the write cannot fail for want of room in practice; the
328            // flag above answers every later call even if it did.
329            let _ = rustix::io::write(&self.bell, &1u64.to_ne_bytes());
330        }
331
332        pub(super) fn next(&self, gather: Duration) -> Vec<FolderChange> {
333            let mut buffer = lock(&self.buffer);
334            let mut batch = Batch::default();
335            let mut deadline: Option<Instant> = None;
336            loop {
337                if self.closed.load(Ordering::SeqCst) {
338                    return Vec::new();
339                }
340                let timeout = match deadline {
341                    None => None,
342                    Some(deadline) => {
343                        let left = deadline.saturating_duration_since(Instant::now());
344                        if left.is_zero() {
345                            return batch.finish();
346                        }
347                        Some(Timespec::try_from(left).unwrap_or(Timespec { tv_sec: 0, tv_nsec: 0 }))
348                    }
349                };
350                let mut fds = [PollFd::new(&self.inotify, PollFlags::IN), PollFd::new(&self.bell, PollFlags::IN)];
351                match poll(&mut fds, timeout.as_ref()) {
352                    Ok(_) | Err(Errno::INTR) => {}
353                    // Polling two descriptors this watch owns does not fail; if it ever does,
354                    // the waiter stops rather than spinning, as if the watch had been dropped.
355                    Err(_) => return Vec::new(),
356                }
357                if !fds[1].revents().is_empty() {
358                    return Vec::new();
359                }
360                if !fds[0].revents().is_empty() {
361                    self.drain(&mut buffer, &mut batch);
362                    if deadline.is_none() && !batch.is_empty() {
363                        deadline = Some(Instant::now() + gather);
364                    }
365                }
366            }
367        }
368
369        /// Reads every event queued so far into `batch`.
370        fn drain(&self, buffer: &mut [MaybeUninit<u8>], batch: &mut Batch) {
371            let mut reader = inotify::Reader::new(&self.inotify, buffer);
372            loop {
373                match reader.next() {
374                    Ok(event) => self.record(&event, batch),
375                    Err(Errno::INTR) => {}
376                    // `AGAIN` is the queue running empty; any other error leaves the rest for the
377                    // next wake-up rather than losing the batch gathered so far.
378                    Err(_) => return,
379                }
380            }
381        }
382
383        fn record(&self, event: &inotify::Event<'_>, batch: &mut Batch) {
384            let flags = event.events();
385            let mut folders = lock(&self.folders);
386            if flags.contains(ReadFlags::QUEUE_OVERFLOW) {
387                let mut all: Vec<&PathBuf> = folders.by_path.keys().collect();
388                all.sort();
389                for folder in all {
390                    batch.push(folder, None, FolderChangeKind::Overflow);
391                }
392                return;
393            }
394            let Some(folder) = folders.by_descriptor.get(&event.wd()).cloned() else {
395                // Unwatched a moment ago, or never known: nobody asked about it.
396                return;
397            };
398            if flags.intersects(ReadFlags::DELETE_SELF | ReadFlags::MOVE_SELF | ReadFlags::UNMOUNT) {
399                // Forget it now, so it is reported once. A moved folder would otherwise go on
400                // reporting under its old path; a removed one is already dropped by the kernel,
401                // which then answers this call with an error nobody needs to hear.
402                folders.forget(event.wd());
403                let _ = inotify::remove_watch(&self.inotify, event.wd());
404                batch.push(&folder, None, FolderChangeKind::Gone);
405                return;
406            }
407            let Some(name) = event.file_name().map(os_name) else {
408                return;
409            };
410            if flags.contains(ReadFlags::MOVED_FROM) {
411                batch.moved_from(event.cookie(), &folder, name);
412            } else if flags.contains(ReadFlags::MOVED_TO) {
413                batch.moved_to(event.cookie(), &folder, name);
414            } else if flags.contains(ReadFlags::CREATE) {
415                batch.push(&folder, Some(name), FolderChangeKind::Created);
416            } else if flags.contains(ReadFlags::DELETE) {
417                batch.push(&folder, Some(name), FolderChangeKind::Removed);
418            } else if flags.intersects(ReadFlags::MODIFY | ReadFlags::ATTRIB) {
419                batch.push(&folder, Some(name), FolderChangeKind::Modified);
420            }
421        }
422    }
423
424    fn os_name(name: &CStr) -> OsString {
425        OsStr::from_bytes(name.to_bytes()).to_os_string()
426    }
427}
428
429#[cfg(not(target_os = "linux"))]
430mod platform {
431    use std::io;
432    use std::path::Path;
433    use std::time::Duration;
434
435    use super::FolderChange;
436
437    /// No event source on this platform, so no value of this type is ever made.
438    #[derive(Debug)]
439    pub(super) enum Source {}
440
441    impl Source {
442        pub(super) fn new() -> io::Result<Self> {
443            Err(io::Error::new(
444                io::ErrorKind::Unsupported,
445                "this platform has no folder watch in this framework; see FolderWatch",
446            ))
447        }
448
449        pub(super) fn watch(&self, _folder: &Path) -> io::Result<()> {
450            match *self {}
451        }
452
453        pub(super) fn unwatch(&self, _folder: &Path) {
454            match *self {}
455        }
456
457        pub(super) fn close(&self) {
458            match *self {}
459        }
460
461        pub(super) fn next(&self, _gather: Duration) -> Vec<FolderChange> {
462            match *self {}
463        }
464    }
465}
466
467#[cfg(test)]
468#[path = "folder_watch_tests.rs"]
469mod tests;