use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const GATHER: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum FolderChangeKind {
Created,
Removed,
Renamed {
from: OsString,
},
Modified,
Gone,
Overflow,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FolderChange {
pub folder: PathBuf,
pub name: Option<OsString>,
pub kind: FolderChangeKind,
}
#[derive(Debug)]
pub struct FolderWatch {
source: Arc<platform::Source>,
}
#[derive(Debug, Clone)]
pub struct FolderChanges {
source: Arc<platform::Source>,
}
impl FolderWatch {
pub fn new() -> io::Result<Self> {
Ok(Self { source: Arc::new(platform::Source::new()?) })
}
pub fn watch(&mut self, folder: &Path) -> io::Result<()> {
self.source.watch(folder)
}
pub fn unwatch(&mut self, folder: &Path) {
self.source.unwatch(folder);
}
#[must_use]
pub fn changes(&self) -> FolderChanges {
FolderChanges { source: Arc::clone(&self.source) }
}
}
impl Drop for FolderWatch {
fn drop(&mut self) {
self.source.close();
}
}
impl FolderChanges {
#[must_use]
pub fn next(&self) -> Vec<FolderChange> {
self.source.next(GATHER, None).unwrap_or_default()
}
#[must_use]
pub fn next_within(&self, bound: Duration) -> Option<Vec<FolderChange>> {
match Instant::now().checked_add(bound) {
Some(limit) => self.source.next(GATHER, Some(limit)),
None => Some(self.next()),
}
}
}
#[derive(Debug, Default)]
struct Batch {
changes: Vec<FolderChange>,
moved_from: Vec<(u32, usize)>,
}
impl Batch {
fn is_empty(&self) -> bool {
self.changes.is_empty()
}
fn push(&mut self, folder: &Path, name: Option<OsString>, kind: FolderChangeKind) {
self.changes.push(FolderChange { folder: folder.to_path_buf(), name, kind });
}
fn moved_from(&mut self, cookie: u32, folder: &Path, name: OsString) {
self.moved_from.push((cookie, self.changes.len()));
self.push(folder, Some(name), FolderChangeKind::Removed);
}
fn moved_to(&mut self, cookie: u32, folder: &Path, name: OsString) {
let first = self.moved_from.iter().position(|(seen, _)| *seen == cookie);
if let Some(position) = first {
let (_, index) = self.moved_from.remove(position);
let earlier = &mut self.changes[index];
if earlier.folder == folder {
let from = earlier.name.take().unwrap_or_default();
earlier.name = Some(name);
earlier.kind = FolderChangeKind::Renamed { from };
return;
}
}
self.push(folder, Some(name), FolderChangeKind::Created);
}
fn finish(self) -> Vec<FolderChange> {
let mut seen = std::collections::HashSet::new();
let mut kept: Vec<FolderChange> =
self.changes.into_iter().rev().filter(|change| seen.insert(change.clone())).collect();
kept.reverse();
kept
}
}
#[cfg(target_os = "linux")]
mod platform {
use std::collections::HashMap;
use std::ffi::{CStr, OsStr, OsString};
use std::io;
use std::mem::MaybeUninit;
use std::os::fd::OwnedFd;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use rustix::event::{EventfdFlags, PollFd, PollFlags, Timespec, eventfd, poll};
use rustix::fs::inotify::{self, CreateFlags, ReadFlags, WatchFlags};
use rustix::io::Errno;
use super::{Batch, FolderChange, FolderChangeKind};
#[derive(Debug)]
pub(super) struct Source {
inotify: OwnedFd,
bell: OwnedFd,
closed: AtomicBool,
folders: Mutex<Folders>,
buffer: Mutex<Vec<MaybeUninit<u8>>>,
}
#[derive(Debug, Default)]
struct Folders {
by_descriptor: HashMap<i32, PathBuf>,
by_path: HashMap<PathBuf, i32>,
}
impl Folders {
fn forget(&mut self, descriptor: i32) -> Option<PathBuf> {
let path = self.by_descriptor.remove(&descriptor)?;
self.by_path.remove(&path);
Some(path)
}
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
const BUFFER: usize = 64 * 1024;
impl Source {
pub(super) fn new() -> io::Result<Self> {
let inotify = inotify::init(CreateFlags::CLOEXEC | CreateFlags::NONBLOCK)?;
let bell = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?;
Ok(Self {
inotify,
bell,
closed: AtomicBool::new(false),
folders: Mutex::new(Folders::default()),
buffer: Mutex::new(vec![MaybeUninit::uninit(); BUFFER]),
})
}
pub(super) fn watch(&self, folder: &Path) -> io::Result<()> {
let mut folders = lock(&self.folders);
if folders.by_path.contains_key(folder) {
return Ok(());
}
let flags = WatchFlags::CREATE
| WatchFlags::DELETE
| WatchFlags::MOVED_FROM
| WatchFlags::MOVED_TO
| WatchFlags::MODIFY
| WatchFlags::ATTRIB
| WatchFlags::DELETE_SELF
| WatchFlags::MOVE_SELF
| WatchFlags::ONLYDIR;
let descriptor = inotify::add_watch(&self.inotify, folder, flags).map_err(|errno| {
if errno == Errno::NOSPC {
io::Error::new(
io::ErrorKind::QuotaExceeded,
"the limit on folder watches (fs.inotify.max_user_watches) is reached",
)
} else {
io::Error::from(errno)
}
})?;
if let Some(earlier) = folders.by_descriptor.insert(descriptor, folder.to_path_buf()) {
folders.by_path.remove(&earlier);
}
folders.by_path.insert(folder.to_path_buf(), descriptor);
Ok(())
}
pub(super) fn unwatch(&self, folder: &Path) {
let mut folders = lock(&self.folders);
if let Some(descriptor) = folders.by_path.get(folder).copied() {
folders.forget(descriptor);
let _ = inotify::remove_watch(&self.inotify, descriptor);
}
}
pub(super) fn close(&self) {
self.closed.store(true, Ordering::SeqCst);
let _ = rustix::io::write(&self.bell, &1u64.to_ne_bytes());
}
pub(super) fn next(&self, gather: Duration, limit: Option<Instant>) -> Option<Vec<FolderChange>> {
let mut buffer = lock(&self.buffer);
let mut batch = Batch::default();
let mut deadline: Option<Instant> = None;
loop {
if self.closed.load(Ordering::SeqCst) {
return Some(Vec::new());
}
let until = match deadline {
Some(deadline) => Some((deadline, true)),
None => limit.map(|limit| (limit, false)),
};
let timeout = match until {
None => None,
Some((until, gathering)) => {
let left = until.saturating_duration_since(Instant::now());
if left.is_zero() {
return gathering.then(|| batch.finish());
}
Some(Timespec::try_from(left).unwrap_or(Timespec { tv_sec: 0, tv_nsec: 0 }))
}
};
let mut fds = [PollFd::new(&self.inotify, PollFlags::IN), PollFd::new(&self.bell, PollFlags::IN)];
match poll(&mut fds, timeout.as_ref()) {
Ok(_) | Err(Errno::INTR) => {}
Err(_) => return Some(Vec::new()),
}
if !fds[1].revents().is_empty() {
return Some(Vec::new());
}
if !fds[0].revents().is_empty() {
self.drain(&mut buffer, &mut batch);
if deadline.is_none() && !batch.is_empty() {
deadline = Some(Instant::now() + gather);
}
}
}
}
fn drain(&self, buffer: &mut [MaybeUninit<u8>], batch: &mut Batch) {
let mut reader = inotify::Reader::new(&self.inotify, buffer);
loop {
match reader.next() {
Ok(event) => self.record(&event, batch),
Err(Errno::INTR) => {}
Err(_) => return,
}
}
}
fn record(&self, event: &inotify::Event<'_>, batch: &mut Batch) {
let flags = event.events();
let mut folders = lock(&self.folders);
if flags.contains(ReadFlags::QUEUE_OVERFLOW) {
let mut all: Vec<&PathBuf> = folders.by_path.keys().collect();
all.sort();
for folder in all {
batch.push(folder, None, FolderChangeKind::Overflow);
}
return;
}
let Some(folder) = folders.by_descriptor.get(&event.wd()).cloned() else {
return;
};
if flags.intersects(ReadFlags::DELETE_SELF | ReadFlags::MOVE_SELF | ReadFlags::UNMOUNT) {
folders.forget(event.wd());
let _ = inotify::remove_watch(&self.inotify, event.wd());
batch.push(&folder, None, FolderChangeKind::Gone);
return;
}
let Some(name) = event.file_name().map(os_name) else {
return;
};
if flags.contains(ReadFlags::MOVED_FROM) {
batch.moved_from(event.cookie(), &folder, name);
} else if flags.contains(ReadFlags::MOVED_TO) {
batch.moved_to(event.cookie(), &folder, name);
} else if flags.contains(ReadFlags::CREATE) {
batch.push(&folder, Some(name), FolderChangeKind::Created);
} else if flags.contains(ReadFlags::DELETE) {
batch.push(&folder, Some(name), FolderChangeKind::Removed);
} else if flags.intersects(ReadFlags::MODIFY | ReadFlags::ATTRIB) {
batch.push(&folder, Some(name), FolderChangeKind::Modified);
}
}
}
fn os_name(name: &CStr) -> OsString {
OsStr::from_bytes(name.to_bytes()).to_os_string()
}
}
#[cfg(not(target_os = "linux"))]
mod platform {
use std::io;
use std::path::Path;
use std::time::{Duration, Instant};
use super::FolderChange;
#[derive(Debug)]
pub(super) enum Source {}
impl Source {
pub(super) fn new() -> io::Result<Self> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"this platform has no folder watch in this framework; see FolderWatch",
))
}
pub(super) fn watch(&self, _folder: &Path) -> io::Result<()> {
match *self {}
}
pub(super) fn unwatch(&self, _folder: &Path) {
match *self {}
}
pub(super) fn close(&self) {
match *self {}
}
pub(super) fn next(&self, _gather: Duration, _limit: Option<Instant>) -> Option<Vec<FolderChange>> {
match *self {}
}
}
}
#[cfg(test)]
#[path = "folder_watch_tests.rs"]
mod tests;