use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, RwLock, Weak};
use std::time::{Duration, SystemTime};
use anyhow::Result;
use notify::{Event, EventKind, RecursiveMode, Watcher};
use tokio::sync::watch;
#[derive(Clone, Copy, Default, PartialEq)]
pub(super) struct Revision {
pub commands: u64,
pub accounts: u64,
executable: u64,
}
pub(super) type Receiver = watch::Receiver<Revision>;
#[derive(Clone)]
pub(super) struct Notifier {
pending: watch::Sender<Revision>,
following: Arc<Mutex<Weak<super::watching::Following>>>,
}
impl Notifier {
pub fn commands(&self) {
self.pending.send_modify(|revision| revision.commands += 1);
}
pub fn follow(&self, changes: Receiver) -> Arc<super::watching::Following> {
let mut current = self.following.lock().unwrap();
if let Some(following) = current.upgrade() {
return following;
}
let following = Arc::new(super::watching::follow(self.clone(), changes));
*current = Arc::downgrade(&following);
following
}
}
#[derive(PartialEq)]
struct Registration {
mode: RecursiveMode,
created: Option<SystemTime>,
#[cfg(unix)]
inode: (u64, u64),
}
impl Registration {
fn at(path: &std::path::Path, mode: RecursiveMode) -> std::io::Result<Self> {
let metadata = std::fs::metadata(path)?;
Ok(Self {
mode,
created: metadata.created().ok(),
#[cfg(unix)]
inode: {
use std::os::unix::fs::MetadataExt;
(metadata.dev(), metadata.ino())
},
})
}
}
struct Paths {
root: PathBuf,
logs: PathBuf,
executable: PathBuf,
}
impl Paths {
fn new(root: PathBuf, logs: PathBuf, executable: PathBuf) -> Self {
Self {
root: observed_path(&root),
logs: observed_path(&logs),
executable: observed_path(&executable),
}
}
fn notice(&self, result: notify::Result<Event>, revision: &mut Revision) {
let event = match result {
Ok(event) if matches!(event.kind, EventKind::Access(_)) => return,
Ok(event) if !event.need_rescan() => event,
error => {
if let Err(error) = error {
eprintln!("thirdshift Console: filesystem observer: {error}");
}
revision.commands += 1;
revision.accounts += 1;
revision.executable += 1;
return;
}
};
for path in event.paths {
if matches!(
event.kind,
EventKind::Modify(notify::event::ModifyKind::Metadata(_))
) && path.is_dir()
{
continue;
}
if path == self.root.join("config.toml") || self.root.starts_with(&path) {
revision.commands += 1;
revision.accounts += 1;
}
if path == self.root.join("console-accounts.json") {
revision.accounts += 1;
}
if path.starts_with(&self.logs)
|| self.logs.starts_with(&path)
|| path.starts_with(self.root.join("console"))
{
revision.commands += 1;
}
if path == self.executable {
revision.executable += 1;
}
}
}
fn watches(&self) -> BTreeMap<PathBuf, RecursiveMode> {
let mut paths = BTreeMap::new();
for (path, recursive) in [
(self.root.clone(), RecursiveMode::NonRecursive),
(self.root.join("console"), RecursiveMode::Recursive),
(self.logs.clone(), RecursiveMode::Recursive),
(
self.executable.parent().unwrap().to_path_buf(),
RecursiveMode::NonRecursive,
),
] {
let ancestor = path.ancestors().find(|path| path.is_dir()).unwrap();
let mode = if ancestor == path {
recursive
} else {
RecursiveMode::NonRecursive
};
paths
.entry(ancestor.to_path_buf())
.and_modify(|old| {
if mode == RecursiveMode::Recursive {
*old = mode;
}
})
.or_insert(mode);
if ancestor == path
&& let Some(parent) = path.parent()
{
paths
.entry(parent.to_path_buf())
.or_insert(RecursiveMode::NonRecursive);
}
}
paths
}
}
pub(super) fn observed_path(path: &std::path::Path) -> PathBuf {
for ancestor in path.ancestors() {
if let Ok(canonical) = ancestor.canonicalize() {
return canonical.join(path.strip_prefix(ancestor).unwrap());
}
}
path.to_path_buf()
}
pub(super) fn observe(
config: &crate::config::UserConfig,
executable: &super::restart::Executable,
) -> Result<(Receiver, Notifier, tokio::task::JoinHandle<()>)> {
let paths = Arc::new(RwLock::new(Paths::new(
crate::config::home()?.join(".thirdshift"),
config.logs_dir.clone(),
executable.path.clone(),
)));
let (pending, mut notifications) = watch::channel(Revision::default());
let notifier = Notifier {
pending: pending.clone(),
following: Arc::default(),
};
let observed = paths.clone();
let enqueue = move |event| {
pending.send_if_modified(|revision| {
let before = (revision.commands, revision.accounts, revision.executable);
observed.read().unwrap().notice(event, revision);
before != (revision.commands, revision.accounts, revision.executable)
});
};
let mut watcher = notify::recommended_watcher(enqueue.clone())?;
let mut recovery =
notify::PollWatcher::new(enqueue, notify::Config::default().with_manual_polling())?;
let mut watched = BTreeMap::new();
let mut recovered = BTreeMap::new();
reconcile(&mut watcher, &mut watched, &paths.read().unwrap());
reconcile(&mut recovery, &mut recovered, &paths.read().unwrap());
let (sender, changes) = watch::channel(Revision::default());
let observer = tokio::spawn(async move {
let mut previous = Revision::default();
let mut recovery_tick = tokio::time::interval(Duration::from_secs(60));
recovery_tick.tick().await;
loop {
let fallback = tokio::select! {
_ = recovery_tick.tick() => true,
result = notifications.changed() => {
if result.is_err() { return; }
false
}
};
if !fallback {
tokio::time::sleep(Duration::from_millis(100)).await;
}
if fallback && let Err(error) = recovery.poll() {
eprintln!("thirdshift Console: filesystem recovery: {error}");
}
let revision = *notifications.borrow_and_update();
if (fallback || revision.accounts != previous.accounts)
&& let Ok(config) = crate::config::UserConfig::load()
{
paths.write().unwrap().logs = observed_path(&config.logs_dir);
}
reconcile(&mut watcher, &mut watched, &paths.read().unwrap());
reconcile(&mut recovery, &mut recovered, &paths.read().unwrap());
sender.send_if_modified(|current| {
let before = *current;
if revision.commands != previous.commands {
current.commands += 1;
}
if fallback || revision.accounts != previous.accounts {
current.accounts += 1;
}
if revision.executable != previous.executable {
current.executable += 1;
}
*current != before
});
previous = revision;
}
});
Ok((changes, notifier, observer))
}
fn reconcile(
watcher: &mut impl Watcher,
watched: &mut BTreeMap<PathBuf, Registration>,
paths: &Paths,
) {
let desired = paths.watches();
for (path, mode) in &desired {
let Ok(registration) = Registration::at(path, *mode) else {
continue;
};
if watched.get(path) != Some(®istration) {
if watched.remove(path).is_some() {
let _ = watcher.unwatch(path);
}
match watcher.watch(path, *mode) {
Ok(()) => {
watched.insert(path.clone(), registration);
}
Err(error) => eprintln!(
"thirdshift Console: can't watch {} (using 60s fallback): {error}",
path.display()
),
}
}
}
watched.retain(|path, _| {
if desired.contains_key(path) {
return true;
}
let _ = watcher.unwatch(path);
false
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_backend_events_match_paths_under_a_symlinked_home() {
let temp = tempfile::tempdir().unwrap();
let real = temp.path().join("real");
std::fs::create_dir(&real).unwrap();
let alias = temp.path().join("alias");
std::os::unix::fs::symlink(&real, &alias).unwrap();
let root = alias.join(".thirdshift");
std::fs::create_dir(&root).unwrap();
let paths = Paths::new(root.clone(), root.join("logs"), alias.join("thirdshift"));
let canonical = real.canonicalize().unwrap().join(".thirdshift");
let mut revision = Revision::default();
for file in [
"config.toml",
"console-accounts.json",
"logs/new.command.json",
] {
paths.notice(
Ok(Event::new(EventKind::Any).add_path(canonical.join(file))),
&mut revision,
);
}
assert_eq!(revision.accounts, 2);
assert_eq!(revision.commands, 2);
}
#[test]
fn directory_metadata_alone_does_not_invalidate_the_inbox() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join(".thirdshift");
std::fs::create_dir(&root).unwrap();
let paths = Paths::new(
root.clone(),
root.join("logs"),
temp.path().join("thirdshift"),
);
let mut revision = Revision::default();
for path in [
temp.path().canonicalize().unwrap(),
root.canonicalize().unwrap(),
] {
paths.notice(
Ok(
Event::new(EventKind::Modify(notify::event::ModifyKind::Metadata(
notify::event::MetadataKind::WriteTime,
)))
.add_path(path),
),
&mut revision,
);
}
assert!(revision == Revision::default());
}
}