#[cfg(test)]
mod fixture;
mod reload;
#[cfg(test)]
mod tests;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError, Weak};
use std::time::Duration;
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher as _};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use crate::catalog::CatalogHandle;
use crate::config::Config;
use crate::error::WatchError;
#[cfg(doc)]
use crate::error::WatchErrorKind;
pub(crate) use crate::watch::reload::Reloader;
const PENDING_EVENTS: usize = 16;
#[must_use = "dropping the watcher stops live reload; hold it for as long as the server serves"]
pub struct Watcher {
platform: Option<Arc<Mutex<RecommendedWatcher>>>,
task: Option<JoinHandle<()>>,
shutdown: Arc<AtomicBool>,
}
impl Watcher {
pub fn start(
source: &Path,
config: Arc<Config>,
catalog: Arc<CatalogHandle>,
) -> Result<Option<Watcher>, WatchError> {
if !config.server.watch {
tracing::info!("[server].watch is false: prompts are read once, at boot");
return Ok(None);
}
if tokio::runtime::Handle::try_current().is_err() {
return Err(WatchError::runtime());
}
let window = config.server.watch_debounce;
let roots = Roots {
prompts: config.paths.prompts.clone(),
config_dir: config_dir(source).to_path_buf(),
};
let interesting = Interesting::new(&roots.prompts, source);
let (events, pending) = mpsc::channel(PENDING_EVENTS);
let broken = Arc::new(AtomicBool::new(false));
let flagged = Arc::clone(&broken);
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
match event {
Ok(event) if interesting.matches(&event) => {
let _queued = events.try_send(());
}
Ok(_ignored) => {}
Err(error) => {
tracing::error!("filesystem watch: {error}");
flagged.store(true, Ordering::Relaxed);
let _queued = events.try_send(());
}
}
})
.map_err(WatchError::create)?;
roots.register(&mut watcher)?;
let watcher = Arc::new(Mutex::new(watcher));
tracing::info!(
"watching {} and {} every {}",
source.display(),
roots.prompts.display(),
humantime::format_duration(window)
);
let reloader = Arc::new(Reloader::new(source, config, catalog));
let shutdown = reloader.cancel_handle();
let repair = Arc::downgrade(&watcher);
let cancel = Arc::clone(&shutdown);
let task = tokio::spawn(async move {
debounce(pending, window, move || {
let reloader = Arc::clone(&reloader);
let broken = Arc::clone(&broken);
let repair = repair.clone();
let roots = roots.clone();
let cancel = Arc::clone(&cancel);
async move {
if cancel.load(Ordering::SeqCst) {
return;
}
if broken.swap(false, Ordering::Relaxed) {
re_register(&repair, &roots);
}
match tokio::task::spawn_blocking(move || reloader.reload()).await {
Ok(Ok(reload)) => {
if reload.retrieval_stale {
tracing::warn!(
"reload kept the previous, now stale, retrieval index; \
run_prompt is unaffected"
);
}
}
Ok(Err(error)) => {
if let Some(cause) = std::error::Error::source(&error) {
tracing::warn!("{error}: {cause}");
} else {
tracing::warn!("{error}");
}
}
Err(join) => tracing::error!("the reload did not finish: {join}"),
}
}
})
.await;
});
Ok(Some(Watcher {
platform: Some(watcher),
task: Some(task),
shutdown,
}))
}
pub async fn shutdown(mut self) {
self.shutdown.store(true, Ordering::SeqCst);
self.platform = None;
if let Some(task) = self.task.take() {
match task.await {
Ok(()) => {}
Err(error) if error.is_cancelled() => {}
Err(error) => tracing::error!("the watch task did not stop cleanly: {error}"),
}
}
}
}
impl Drop for Watcher {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(task) = self.task.take() {
task.abort();
}
}
}
impl std::fmt::Debug for Watcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Watcher")
.field(
"watching",
&self.task.as_ref().is_some_and(|task| !task.is_finished()),
)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
struct Roots {
prompts: PathBuf,
config_dir: PathBuf,
}
impl Roots {
fn register(&self, watcher: &mut RecommendedWatcher) -> Result<(), WatchError> {
watch(watcher, &self.prompts, RecursiveMode::Recursive)?;
watch(watcher, &self.config_dir, RecursiveMode::NonRecursive)
}
}
fn re_register(watcher: &Weak<Mutex<RecommendedWatcher>>, roots: &Roots) {
let Some(watcher) = watcher.upgrade() else {
return;
};
let mut watcher = watcher.lock().unwrap_or_else(PoisonError::into_inner);
match roots.register(&mut watcher) {
Ok(()) => tracing::info!(
"re-registered the watch on {} and {}",
roots.prompts.display(),
roots.config_dir.display()
),
Err(error) => tracing::error!(
"live reload has stopped and saved prompts will no longer be picked up: {error}. \
Restart the server once the path is back."
),
}
}
fn watch(
watcher: &mut RecommendedWatcher,
path: &Path,
mode: RecursiveMode,
) -> Result<(), WatchError> {
watcher
.watch(path, mode)
.map_err(|error| WatchError::watch(path.to_path_buf(), error))
}
fn config_dir(source: &Path) -> &Path {
match source.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
}
}
async fn debounce<F, Fut>(mut events: mpsc::Receiver<()>, window: Duration, mut on_settled: F)
where
F: FnMut() -> Fut,
Fut: Future<Output = ()>,
{
while events.recv().await.is_some() {
loop {
match tokio::time::timeout(window, events.recv()).await {
Ok(Some(())) => {}
Ok(None) => {
on_settled().await;
return;
}
Err(_window_closed) => break,
}
}
on_settled().await;
}
}
fn plain(path: &Path) -> Cow<'_, Path> {
match path.to_str().and_then(|text| text.strip_prefix(r"\\?\")) {
Some(stripped) => Cow::Owned(PathBuf::from(stripped)),
None => Cow::Borrowed(path),
}
}
fn forms(path: &Path) -> Vec<PathBuf> {
let mut forms = vec![plain(path).into_owned()];
let resolved = [
std::path::absolute(path).ok(),
std::fs::canonicalize(path).ok(),
];
for form in resolved.into_iter().flatten() {
let form = plain(&form).into_owned();
if !forms.contains(&form) {
forms.push(form);
}
}
forms
}
struct Interesting {
roots: Vec<PathBuf>,
config: Vec<PathBuf>,
}
impl Interesting {
fn new(prompts: &Path, source: &Path) -> Interesting {
Interesting {
roots: forms(prompts),
config: forms(source),
}
}
fn matches(&self, event: ¬ify::Event) -> bool {
if matches!(event.kind, EventKind::Access(_)) {
return false;
}
event.paths.iter().any(|path| self.watched(path))
}
fn watched(&self, path: &Path) -> bool {
let path = plain(path);
self.roots.iter().any(|root| path.starts_with(root))
|| self
.config
.iter()
.any(|file| path.as_ref() == file.as_path())
}
}