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;