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, Instant};
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, None).unwrap_or_default()
159    }
160
161    /// Waits like [`next`](Self::next), but at most `bound` for the first change; `None` when
162    /// nothing changed in that time. The watch goes on: ask again.
163    ///
164    /// Made for screen tests: a [`Harness`](crate::runtime::Harness) runs the work of a
165    /// [`Command::perform`](crate::runtime::Command::perform) on the spot, so a wait with no end
166    /// would hold the test for good. A running application has a thread for its watch and keeps
167    /// using `next`. Once a change has come, the tenth of a second that gathers its batch is
168    /// waited in full, so a batch is never cut short by the bound.
169    #[must_use]
170    pub fn next_within(&self, bound: Duration) -> Option<Vec<FolderChange>> {
171        // A bound so far off that the clock cannot name it is the unbounded wait.
172        match Instant::now().checked_add(bound) {
173            Some(limit) => self.source.next(GATHER, Some(limit)),
174            None => Some(self.next()),
175        }
176    }
177}
178
179/// Collects one batch of changes: renames paired, repeats dropped, order kept.
180#[derive(Debug, Default)]
181struct Batch {
182    changes: Vec<FolderChange>,
183    /// Where each half-seen rename stands in `changes`, by the cookie that pairs its two halves.
184    moved_from: Vec<(u32, usize)>,
185}
186
187impl Batch {
188    fn is_empty(&self) -> bool {
189        self.changes.is_empty()
190    }
191
192    fn push(&mut self, folder: &Path, name: Option<OsString>, kind: FolderChangeKind) {
193        self.changes.push(FolderChange { folder: folder.to_path_buf(), name, kind });
194    }
195
196    /// The first half of a move: the entry left `folder`. It stays a removal unless the second
197    /// half arrives in the same folder.
198    fn moved_from(&mut self, cookie: u32, folder: &Path, name: OsString) {
199        self.moved_from.push((cookie, self.changes.len()));
200        self.push(folder, Some(name), FolderChangeKind::Removed);
201    }
202
203    /// The second half of a move: the entry arrived in `folder` under `name`.
204    fn moved_to(&mut self, cookie: u32, folder: &Path, name: OsString) {
205        let first = self.moved_from.iter().position(|(seen, _)| *seen == cookie);
206        if let Some(position) = first {
207            let (_, index) = self.moved_from.remove(position);
208            let earlier = &mut self.changes[index];
209            if earlier.folder == folder {
210                let from = earlier.name.take().unwrap_or_default();
211                earlier.name = Some(name);
212                earlier.kind = FolderChangeKind::Renamed { from };
213                return;
214            }
215        }
216        self.push(folder, Some(name), FolderChangeKind::Created);
217    }
218
219    /// The batch in the order it happened, each change once. A repeated change keeps its last
220    /// place, so "created, removed, created again" ends as the entry being there.
221    fn finish(self) -> Vec<FolderChange> {
222        let mut seen = std::collections::HashSet::new();
223        let mut kept: Vec<FolderChange> =
224            self.changes.into_iter().rev().filter(|change| seen.insert(change.clone())).collect();
225        kept.reverse();
226        kept
227    }
228}
229
230#[cfg(target_os = "linux")]
231mod platform {
232    use std::collections::HashMap;
233    use std::ffi::{CStr, OsStr, OsString};
234    use std::io;
235    use std::mem::MaybeUninit;
236    use std::os::fd::OwnedFd;
237    use std::os::unix::ffi::OsStrExt;
238    use std::path::{Path, PathBuf};
239    use std::sync::atomic::{AtomicBool, Ordering};
240    use std::sync::{Mutex, MutexGuard, PoisonError};
241    use std::time::{Duration, Instant};
242
243    use rustix::event::{EventfdFlags, PollFd, PollFlags, Timespec, eventfd, poll};
244    use rustix::fs::inotify::{self, CreateFlags, ReadFlags, WatchFlags};
245    use rustix::io::Errno;
246
247    use super::{Batch, FolderChange, FolderChangeKind};
248
249    /// The inotify instance, the folders it watches, and the bell that ends a wait.
250    #[derive(Debug)]
251    pub(super) struct Source {
252        inotify: OwnedFd,
253        /// Written once when the watch is dropped, and never read: staying readable is what
254        /// wakes every waiter, the present one and any later one.
255        bell: OwnedFd,
256        closed: AtomicBool,
257        folders: Mutex<Folders>,
258        /// Held by the one thread reading at a time, so two waiters never split a batch.
259        buffer: Mutex<Vec<MaybeUninit<u8>>>,
260    }
261
262    /// Watched folders by the descriptor inotify gave them, and the way back.
263    #[derive(Debug, Default)]
264    struct Folders {
265        by_descriptor: HashMap<i32, PathBuf>,
266        by_path: HashMap<PathBuf, i32>,
267    }
268
269    impl Folders {
270        fn forget(&mut self, descriptor: i32) -> Option<PathBuf> {
271            let path = self.by_descriptor.remove(&descriptor)?;
272            self.by_path.remove(&path);
273            Some(path)
274        }
275    }
276
277    fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
278        // A panic on another thread leaves the map and the buffer usable; the watch goes on.
279        mutex.lock().unwrap_or_else(PoisonError::into_inner)
280    }
281
282    /// Room for a few hundred events per read; the kernel queues the rest.
283    const BUFFER: usize = 64 * 1024;
284
285    impl Source {
286        pub(super) fn new() -> io::Result<Self> {
287            // Close-on-exec, so a child process never inherits the watch; non-blocking, because
288            // the waiting is done by `poll`, which can also hear the bell.
289            let inotify = inotify::init(CreateFlags::CLOEXEC | CreateFlags::NONBLOCK)?;
290            let bell = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?;
291            Ok(Self {
292                inotify,
293                bell,
294                closed: AtomicBool::new(false),
295                folders: Mutex::new(Folders::default()),
296                buffer: Mutex::new(vec![MaybeUninit::uninit(); BUFFER]),
297            })
298        }
299
300        pub(super) fn watch(&self, folder: &Path) -> io::Result<()> {
301            let mut folders = lock(&self.folders);
302            if folders.by_path.contains_key(folder) {
303                return Ok(());
304            }
305            let flags = WatchFlags::CREATE
306                | WatchFlags::DELETE
307                | WatchFlags::MOVED_FROM
308                | WatchFlags::MOVED_TO
309                | WatchFlags::MODIFY
310                | WatchFlags::ATTRIB
311                | WatchFlags::DELETE_SELF
312                | WatchFlags::MOVE_SELF
313                | WatchFlags::ONLYDIR;
314            let descriptor = inotify::add_watch(&self.inotify, folder, flags).map_err(|errno| {
315                if errno == Errno::NOSPC {
316                    // The kernel says "no space on device", which reads like a full disk.
317                    io::Error::new(
318                        io::ErrorKind::QuotaExceeded,
319                        "the limit on folder watches (fs.inotify.max_user_watches) is reached",
320                    )
321                } else {
322                    io::Error::from(errno)
323                }
324            })?;
325            // Two paths to one folder share a descriptor; the later path is the one reported.
326            if let Some(earlier) = folders.by_descriptor.insert(descriptor, folder.to_path_buf()) {
327                folders.by_path.remove(&earlier);
328            }
329            folders.by_path.insert(folder.to_path_buf(), descriptor);
330            Ok(())
331        }
332
333        pub(super) fn unwatch(&self, folder: &Path) {
334            let mut folders = lock(&self.folders);
335            if let Some(descriptor) = folders.by_path.get(folder).copied() {
336                folders.forget(descriptor);
337                // Events already queued for it are dropped when read: nobody knows the descriptor.
338                let _ = inotify::remove_watch(&self.inotify, descriptor);
339            }
340        }
341
342        pub(super) fn close(&self) {
343            self.closed.store(true, Ordering::SeqCst);
344            // The counter only grows, so the write cannot fail for want of room in practice; the
345            // flag above answers every later call even if it did.
346            let _ = rustix::io::write(&self.bell, &1u64.to_ne_bytes());
347        }
348
349        /// The next batch, `None` when `limit` passes before the first change of one.
350        pub(super) fn next(&self, gather: Duration, limit: Option<Instant>) -> Option<Vec<FolderChange>> {
351            let mut buffer = lock(&self.buffer);
352            let mut batch = Batch::default();
353            let mut deadline: Option<Instant> = None;
354            loop {
355                if self.closed.load(Ordering::SeqCst) {
356                    return Some(Vec::new());
357                }
358                // Until a change arrives the wait runs to the caller's limit, if any; after it,
359                // to the end of the gathering.
360                let until = match deadline {
361                    Some(deadline) => Some((deadline, true)),
362                    None => limit.map(|limit| (limit, false)),
363                };
364                let timeout = match until {
365                    None => None,
366                    Some((until, gathering)) => {
367                        let left = until.saturating_duration_since(Instant::now());
368                        if left.is_zero() {
369                            return gathering.then(|| batch.finish());
370                        }
371                        Some(Timespec::try_from(left).unwrap_or(Timespec { tv_sec: 0, tv_nsec: 0 }))
372                    }
373                };
374                let mut fds = [PollFd::new(&self.inotify, PollFlags::IN), PollFd::new(&self.bell, PollFlags::IN)];
375                match poll(&mut fds, timeout.as_ref()) {
376                    Ok(_) | Err(Errno::INTR) => {}
377                    // Polling two descriptors this watch owns does not fail; if it ever does,
378                    // the waiter stops rather than spinning, as if the watch had been dropped.
379                    Err(_) => return Some(Vec::new()),
380                }
381                if !fds[1].revents().is_empty() {
382                    return Some(Vec::new());
383                }
384                if !fds[0].revents().is_empty() {
385                    self.drain(&mut buffer, &mut batch);
386                    if deadline.is_none() && !batch.is_empty() {
387                        deadline = Some(Instant::now() + gather);
388                    }
389                }
390            }
391        }
392
393        /// Reads every event queued so far into `batch`.
394        fn drain(&self, buffer: &mut [MaybeUninit<u8>], batch: &mut Batch) {
395            let mut reader = inotify::Reader::new(&self.inotify, buffer);
396            loop {
397                match reader.next() {
398                    Ok(event) => self.record(&event, batch),
399                    Err(Errno::INTR) => {}
400                    // `AGAIN` is the queue running empty; any other error leaves the rest for the
401                    // next wake-up rather than losing the batch gathered so far.
402                    Err(_) => return,
403                }
404            }
405        }
406
407        fn record(&self, event: &inotify::Event<'_>, batch: &mut Batch) {
408            let flags = event.events();
409            let mut folders = lock(&self.folders);
410            if flags.contains(ReadFlags::QUEUE_OVERFLOW) {
411                let mut all: Vec<&PathBuf> = folders.by_path.keys().collect();
412                all.sort();
413                for folder in all {
414                    batch.push(folder, None, FolderChangeKind::Overflow);
415                }
416                return;
417            }
418            let Some(folder) = folders.by_descriptor.get(&event.wd()).cloned() else {
419                // Unwatched a moment ago, or never known: nobody asked about it.
420                return;
421            };
422            if flags.intersects(ReadFlags::DELETE_SELF | ReadFlags::MOVE_SELF | ReadFlags::UNMOUNT) {
423                // Forget it now, so it is reported once. A moved folder would otherwise go on
424                // reporting under its old path; a removed one is already dropped by the kernel,
425                // which then answers this call with an error nobody needs to hear.
426                folders.forget(event.wd());
427                let _ = inotify::remove_watch(&self.inotify, event.wd());
428                batch.push(&folder, None, FolderChangeKind::Gone);
429                return;
430            }
431            let Some(name) = event.file_name().map(os_name) else {
432                return;
433            };
434            if flags.contains(ReadFlags::MOVED_FROM) {
435                batch.moved_from(event.cookie(), &folder, name);
436            } else if flags.contains(ReadFlags::MOVED_TO) {
437                batch.moved_to(event.cookie(), &folder, name);
438            } else if flags.contains(ReadFlags::CREATE) {
439                batch.push(&folder, Some(name), FolderChangeKind::Created);
440            } else if flags.contains(ReadFlags::DELETE) {
441                batch.push(&folder, Some(name), FolderChangeKind::Removed);
442            } else if flags.intersects(ReadFlags::MODIFY | ReadFlags::ATTRIB) {
443                batch.push(&folder, Some(name), FolderChangeKind::Modified);
444            }
445        }
446    }
447
448    fn os_name(name: &CStr) -> OsString {
449        OsStr::from_bytes(name.to_bytes()).to_os_string()
450    }
451}
452
453#[cfg(not(target_os = "linux"))]
454mod platform {
455    use std::io;
456    use std::path::Path;
457    use std::time::{Duration, Instant};
458
459    use super::FolderChange;
460
461    /// No event source on this platform, so no value of this type is ever made.
462    #[derive(Debug)]
463    pub(super) enum Source {}
464
465    impl Source {
466        pub(super) fn new() -> io::Result<Self> {
467            Err(io::Error::new(
468                io::ErrorKind::Unsupported,
469                "this platform has no folder watch in this framework; see FolderWatch",
470            ))
471        }
472
473        pub(super) fn watch(&self, _folder: &Path) -> io::Result<()> {
474            match *self {}
475        }
476
477        pub(super) fn unwatch(&self, _folder: &Path) {
478            match *self {}
479        }
480
481        pub(super) fn close(&self) {
482            match *self {}
483        }
484
485        pub(super) fn next(&self, _gather: Duration, _limit: Option<Instant>) -> Option<Vec<FolderChange>> {
486            match *self {}
487        }
488    }
489}
490
491#[cfg(test)]
492#[path = "folder_watch_tests.rs"]
493mod tests;