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;