Skip to main content

folk_core/
watch.rs

1//! Dev-mode file watcher: triggers hot reload when PHP files change.
2//!
3//! Watches the configured directories recursively and, after a debounce
4//! window, calls [`WorkerPool::trigger_reload`] which invalidates the `OPcache`
5//! and recycles recyclable workers. Disabled unless `[dev] watch = true`.
6//!
7//! The watcher is intended for development only. The main PHP thread (worker
8//! #1) is not recyclable, so a single-worker (NTS) server cannot fully hot
9//! reload — run with `workers.count > 1` (ZTS) for reliable reloads.
10
11use std::path::Path;
12use std::sync::Arc;
13
14use anyhow::{Context, Result};
15use notify_debouncer_mini::notify::{RecommendedWatcher, RecursiveMode};
16use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
17use tracing::{info, warn};
18
19use crate::config::DevConfig;
20use crate::worker_pool::WorkerPool;
21
22/// Active file watcher. Dropping it stops watching, so the caller must keep it
23/// alive for the lifetime of the server.
24pub type WatchGuard = Debouncer<RecommendedWatcher>;
25
26/// Start the dev-mode file watcher.
27///
28/// Must be called from within a Tokio runtime (the debounce callback spawns the
29/// reload task onto the current runtime). Returns a guard that must be kept
30/// alive; dropping it stops the watcher.
31pub fn spawn(dev: &DevConfig, pool: Arc<WorkerPool>) -> Result<WatchGuard> {
32    let handle = tokio::runtime::Handle::current();
33    let extensions: Vec<String> = dev
34        .watch_extensions
35        .iter()
36        .map(|e| e.trim_start_matches('.').to_ascii_lowercase())
37        .collect();
38
39    let mut debouncer = new_debouncer(dev.debounce, move |res: DebounceEventResult| match res {
40        Ok(events) => {
41            let changed = events
42                .iter()
43                .any(|e| matches_extension(&e.path, &extensions));
44            if changed {
45                let pool = pool.clone();
46                handle.spawn(async move { pool.trigger_reload().await });
47            }
48        },
49        Err(error) => {
50            warn!(%error, "file watcher error");
51        },
52    })
53    .context("failed to create file watcher")?;
54
55    let mut watched = 0usize;
56    for path in &dev.watch_paths {
57        let p = Path::new(path);
58        if !p.exists() {
59            warn!(path, "watch path does not exist, skipping");
60            continue;
61        }
62        debouncer
63            .watcher()
64            .watch(p, RecursiveMode::Recursive)
65            .with_context(|| format!("failed to watch {path}"))?;
66        watched += 1;
67    }
68
69    if watched == 0 {
70        warn!("hot reload enabled but no watch paths exist; watcher is idle");
71    } else {
72        info!(
73            paths = ?dev.watch_paths,
74            extensions = ?dev.watch_extensions,
75            debounce_ms = u64::try_from(dev.debounce.as_millis()).unwrap_or(u64::MAX),
76            "hot reload watcher started"
77        );
78    }
79
80    Ok(debouncer)
81}
82
83/// Whether `path`'s extension is in the watched set (case-insensitive).
84fn matches_extension(path: &Path, extensions: &[String]) -> bool {
85    path.extension()
86        .and_then(|e| e.to_str())
87        .map(str::to_ascii_lowercase)
88        .is_some_and(|ext| extensions.contains(&ext))
89}