use super::*;
pub(crate) fn watch_ignored_patterns(root: &Path, ignored: &[String]) -> Vec<glob::Pattern> {
let mut out = Vec::new();
for raw in ignored {
let raw = raw.trim();
if raw.is_empty() {
continue;
}
if let Ok(p) = glob::Pattern::new(raw) {
out.push(p);
}
if !raw.starts_with('/') && !raw.starts_with("**") {
let rooted = root.join(raw).to_string_lossy().replace('\\', "/");
if let Ok(p) = glob::Pattern::new(&rooted) {
out.push(p);
}
}
}
out
}
pub(crate) fn is_watch_ignored(patterns: &[glob::Pattern], root: &Path, path: &Path) -> bool {
if patterns.is_empty() {
return false;
}
let opts = glob::MatchOptions {
require_literal_separator: true,
..Default::default()
};
let rel = path.strip_prefix(root).ok();
patterns.iter().any(|p| {
p.matches_path_with(path, opts) || rel.is_some_and(|r| p.matches_path_with(r, opts))
})
}
pub(crate) fn is_config_dependency(path: &Path) -> bool {
let deps = plugins::config_dependencies();
if deps.is_empty() {
return false;
}
let real = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
deps.iter().any(|d| {
!d.components().any(|c| c.as_os_str() == "node_modules")
&& (d == path || std::fs::canonicalize(d).is_ok_and(|r| r == real))
})
}
pub(crate) fn is_restart_trigger(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if name == ".env" || name.starts_with(".env.") {
return true;
}
let stem_ext = |bases: &[&str], exts: &[&str]| {
bases
.iter()
.any(|b| exts.iter().any(|e| name == format!("{b}.{e}")))
};
stem_ext(
&[
"vite.config",
"oj.config",
"postcss.config",
"tailwind.config",
],
&["ts", "js", "mjs", "cjs", "mts", "cts", "json"],
)
}
pub(crate) fn is_tsconfig_file(path: &Path) -> bool {
path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
n == "tsconfig.json" || (n.starts_with("tsconfig.") && n.ends_with(".json"))
})
}
pub(crate) mod child_groups {
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
struct Child {
pid: u32,
own_group: bool,
start: Option<u64>,
}
static CHILDREN: Mutex<Vec<Child>> = Mutex::new(Vec::new());
static REGISTRY: OnceLock<PathBuf> = OnceLock::new();
pub fn register(pid: u32, own_group: bool) {
let start = proc_start_time(pid);
let mut children = CHILDREN.lock().unwrap();
children.push(Child {
pid,
own_group,
start,
});
persist(&children);
}
pub fn unregister(pid: u32) {
let mut children = CHILDREN.lock().unwrap();
children.retain(|c| c.pid != pid);
persist(&children);
}
#[cfg(target_os = "linux")]
pub(crate) fn proc_start_time(pid: u32) -> Option<u64> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rsplit_once(')')?.1;
after_comm.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(target_os = "macos")]
pub(crate) fn proc_start_time(pid: u32) -> Option<u64> {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
let got = unsafe {
libc::proc_pidinfo(
pid as libc::c_int,
libc::PROC_PIDTBSDINFO,
0,
&mut info as *mut _ as *mut libc::c_void,
size,
)
};
(got == size).then(|| info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn proc_start_time(_pid: u32) -> Option<u64> {
None
}
fn snapshot(children: &[Child]) -> String {
let own = std::process::id();
let kids: Vec<serde_json::Value> = children
.iter()
.map(
|c| serde_json::json!({ "pid": c.pid, "own_group": c.own_group, "start": c.start }),
)
.collect();
serde_json::json!({
"owner": { "pid": own, "start": proc_start_time(own) },
"children": kids,
})
.to_string()
}
fn persist(children: &[Child]) {
let Some(file) = REGISTRY.get() else { return };
let tmp = file.with_extension("json.tmp");
if std::fs::write(&tmp, snapshot(children)).is_ok() {
let _ = std::fs::rename(&tmp, file);
}
}
pub fn init_registry(dir: &Path) {
if std::fs::create_dir_all(dir).is_err() {
return;
}
let reaped = sweep_stale(dir);
if reaped > 0 {
eprintln!("oj: reaped {reaped} orphaned child process(es) from a previous run");
}
let _ = REGISTRY.set(dir.join(format!("{}.json", std::process::id())));
persist(&CHILDREN.lock().unwrap());
}
pub fn sweep_stale(dir: &Path) -> usize {
let mut reaped = 0usize;
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let own_file = format!("{}.json", std::process::id());
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json")
|| path.file_name().and_then(|n| n.to_str()) == Some(own_file.as_str())
{
continue;
}
let parsed: Option<serde_json::Value> = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok());
let Some(doc) = parsed else {
let _ = std::fs::remove_file(&path);
continue;
};
if owner_alive(&doc["owner"]) {
continue;
}
for child in doc["children"].as_array().map(Vec::as_slice).unwrap_or(&[]) {
let (Some(pid), Some(start)) = (child["pid"].as_u64(), child["start"].as_u64())
else {
continue;
};
if proc_start_time(pid as u32) != Some(start) {
continue;
}
reaped += kill_verified(pid as u32, child["own_group"].as_bool().unwrap_or(false));
}
let _ = std::fs::remove_file(&path);
}
reaped
}
fn owner_alive(owner: &serde_json::Value) -> bool {
let Some(pid) = owner["pid"].as_u64() else {
return false;
};
match owner["start"].as_u64() {
Some(start) => proc_start_time(pid as u32) == Some(start),
#[cfg(unix)]
None => unsafe { libc::kill(pid as i32, 0) == 0 },
#[cfg(not(unix))]
None => false,
}
}
#[cfg(unix)]
fn kill_verified(pid: u32, own_group: bool) -> usize {
let pid_i = pid as i32;
let target = if own_group {
if unsafe { libc::getpgid(pid_i) } != pid_i {
return 0;
}
-pid_i
} else {
pid_i
};
usize::from(unsafe { libc::kill(target, libc::SIGKILL) } == 0)
}
#[cfg(not(unix))]
fn kill_verified(_pid: u32, _own_group: bool) -> usize {
0
}
pub fn kill_all() -> usize {
let mut killed = 0usize;
#[cfg(unix)]
for _round in 0..3 {
let children: Vec<Child> = std::mem::take(&mut *CHILDREN.lock().unwrap());
if children.is_empty() {
break;
}
for &Child { pid, own_group, .. } in &children {
let pid_i = pid as i32;
let target = if own_group {
if unsafe { libc::getpgid(pid_i) } != pid_i {
continue;
}
-pid_i
} else {
if unsafe { libc::kill(pid_i, 0) } != 0 {
continue;
}
pid_i
};
if unsafe { libc::kill(target, libc::SIGKILL) } == 0 {
killed += 1;
}
}
for child in &children {
let deadline = Instant::now() + Duration::from_millis(200);
loop {
let r = unsafe {
libc::waitpid(child.pid as i32, std::ptr::null_mut(), libc::WNOHANG)
};
if r != 0 || Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
}
}
let children = CHILDREN.lock().unwrap();
if children.is_empty() {
if let Some(file) = REGISTRY.get() {
let _ = std::fs::remove_file(file);
}
} else {
persist(&children);
}
killed
}
}
pub fn register_child(pid: u32) {
child_groups::register(pid, false);
}
pub fn unregister_child(pid: u32) {
child_groups::unregister(pid);
}
pub fn restart_process(reason: &str) -> ! {
if let Some(hook) = RESTART_HOOK.get() {
hook(reason);
}
eprintln!("{} {reason} — restarting dev server", oj_brand());
oj_diag::emit(
oj_diag::Event::new(
oj_diag::Kind::Restart,
format!("{reason}; restarting dev server"),
)
.warn(),
);
let killed = child_groups::kill_all();
if killed > 0 {
eprintln!("oj: restart killed {killed} child process(es)");
}
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("oj"));
let args: Vec<String> = std::env::args().skip(1).collect();
let launch_env = oj_env::get().vars_os();
let launch_dir = STARTUP_CWD.get().filter(|d| d.is_dir());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let mut cmd = std::process::Command::new(&exe);
cmd.args(&args).env_clear().envs(launch_env);
if let Some(dir) = launch_dir {
cmd.current_dir(dir);
}
let err = cmd.exec();
eprintln!("oj: restart failed: {err}");
std::process::exit(1);
}
#[cfg(not(unix))]
{
let mut cmd = std::process::Command::new(&exe);
cmd.args(&args).env_clear().envs(launch_env);
if let Some(dir) = launch_dir {
cmd.current_dir(dir);
}
let code = cmd.status().ok().and_then(|s| s.code()).unwrap_or(0);
std::process::exit(code);
}
}
static RESTART_HOOK: std::sync::OnceLock<fn(&str) -> !> = std::sync::OnceLock::new();
pub fn set_restart_hook(hook: fn(&str) -> !) {
let _ = RESTART_HOOK.set(hook);
}
pub(crate) static STARTUP_CWD: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
pub fn capture_startup_cwd() {
if let Ok(dir) = std::env::current_dir() {
let _ = STARTUP_CWD.set(dir);
}
}
fn new_watcher<F: notify::EventHandler>(handler: F) -> notify::Result<notify::RecommendedWatcher> {
use notify::Watcher;
notify::RecommendedWatcher::new(
handler,
notify::Config::default().with_event_kinds(notify::EventKindMask::CORE),
)
}
pub(crate) enum WatchMsg {
Fs(notify::Result<notify::Event>),
Dir(PathBuf),
Close(std::sync::mpsc::Sender<()>),
}
#[derive(Clone, Default)]
pub struct WatchFeed(pub(crate) Arc<Mutex<Vec<std::sync::mpsc::Sender<notify::Event>>>>);
impl WatchFeed {
pub fn subscribe(&self) -> std::sync::mpsc::Receiver<notify::Event> {
let (tx, rx) = std::sync::mpsc::channel();
self.0.lock().unwrap().push(tx);
rx
}
pub(crate) fn publish(
&self,
ev: ¬ify::Event,
ignored: &[glob::Pattern],
root: &Path,
cache_base: &Path,
) {
let mut subscribers = self.0.lock().unwrap();
if subscribers.is_empty() {
return;
}
let mut ev = ev.clone();
ev.paths
.retain(|p| !watch_excluded(ignored, root, cache_base, p));
if ev.paths.is_empty() {
return;
}
subscribers.retain(|tx| tx.send(ev.clone()).is_ok());
}
}
pub(crate) fn watch_excluded(
ignored: &[glob::Pattern],
root: &Path,
cache_base: &Path,
path: &Path,
) -> bool {
path.starts_with(cache_base)
|| path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(is_config_timestamp_temp)
|| is_watch_ignored(ignored, root, path)
}
fn is_config_timestamp_temp(name: &str) -> bool {
name.ends_with(".mjs")
&& name.find(".timestamp-").is_some_and(|i| {
name.as_bytes()
.get(i + ".timestamp-".len())
.is_some_and(u8::is_ascii_digit)
})
}
pub(crate) fn ensure_watched_file(state: &ServerState, file: &Path) {
if file.starts_with(&state.root) || file.components().any(|c| c.as_os_str() == "node_modules") {
return;
}
if let Some(dir) = file.parent() {
let _ = state.watch_tx.send(WatchMsg::Dir(dir.to_path_buf()));
}
}
fn watch_served_dir(
watchers: &mut Watchers,
watched: &mut std::collections::HashSet<PathBuf>,
dir: PathBuf,
) {
if !watched.contains(&dir)
&& watchers
.watch(&dir, notify::RecursiveMode::NonRecursive)
.is_ok()
{
watched.insert(dir);
}
}
type EventSink = Arc<dyn Fn(notify::Result<notify::Event>) + Send + Sync>;
const MAX_OWN_STREAMS: usize = 128;
struct Watchers {
root: notify::RecommendedWatcher,
sink: EventSink,
own: std::collections::HashMap<PathBuf, notify::RecommendedWatcher>,
}
impl Watchers {
fn watch(&mut self, path: &Path, mode: notify::RecursiveMode) -> notify::Result<()> {
use notify::Watcher;
if !cfg!(target_os = "macos") || self.own.len() >= MAX_OWN_STREAMS {
return self.root.watch(path, mode);
}
if self.own.contains_key(path) {
return Ok(());
}
let sink = Arc::clone(&self.sink);
let mut watcher = new_watcher(move |ev| sink(ev))?;
watcher.watch(path, mode)?;
self.own.insert(path.to_path_buf(), watcher);
Ok(())
}
}
fn is_unwatched_dir(name: &std::ffi::OsStr) -> bool {
matches!(
name.to_str(),
Some("node_modules" | ".oj-cache" | "dist" | ".git")
)
}
fn watch_root(
watcher: &mut notify::RecommendedWatcher,
root: &Path,
cache_base: &Path,
) -> notify::Result<()> {
use notify::{RecursiveMode, Watcher};
let root_watched = watcher.watch(root, RecursiveMode::NonRecursive).is_ok();
let mut watched_any = root_watched;
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
if is_unwatched_dir(&entry.file_name()) || entry.path() == cache_base {
continue;
}
let path = entry.path();
let mode = if path.is_dir() {
RecursiveMode::Recursive
} else if !root_watched || entry.file_type().is_ok_and(|t| t.is_symlink()) {
RecursiveMode::NonRecursive
} else {
continue;
};
if watcher.watch(&path, mode).is_ok() {
watched_any = true;
}
}
}
if watched_any {
Ok(())
} else {
watcher.watch(root, RecursiveMode::Recursive)
}
}
pub(crate) fn created_dirs(
ignored: &[glob::Pattern],
root: &Path,
cache_base: &Path,
ev: ¬ify::Event,
) -> Vec<PathBuf> {
if !matches!(
ev.kind,
notify::EventKind::Create(_)
| notify::EventKind::Modify(notify::event::ModifyKind::Name(_))
) {
return Vec::new();
}
ev.paths
.iter()
.filter(|path| watched_subtree(ignored, root, cache_base, path) && path.is_dir())
.cloned()
.collect()
}
fn watched_subtree(ignored: &[glob::Pattern], root: &Path, cache_base: &Path, path: &Path) -> bool {
let Ok(rel) = path.strip_prefix(root) else {
return false;
};
let mut parts = rel.components().map(|c| c.as_os_str());
!parts.clone().any(|c| c == "node_modules" || c == ".git")
&& !parts.next().is_some_and(is_unwatched_dir)
&& !watch_excluded(ignored, root, cache_base, path)
}
fn adopt_created_dirs(
watchers: &mut Watchers,
ignored: &[glob::Pattern],
root: &Path,
cache_base: &Path,
ev: ¬ify::Event,
) -> Vec<PathBuf> {
let mut found = Vec::new();
for path in created_dirs(ignored, root, cache_base, ev) {
if path.parent() == Some(root)
&& watchers
.watch(&path, notify::RecursiveMode::Recursive)
.is_err()
{
continue;
}
scan_files(&path, &mut found);
}
found
}
pub(crate) fn scan_files(dir: &Path, out: &mut Vec<PathBuf>) {
scan_tree(dir, out, &mut Vec::new());
}
fn scan_tree(dir: &Path, out: &mut Vec<PathBuf>, dirs: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(t) if t.is_dir() => {
if !matches!(entry.file_name().to_str(), Some("node_modules" | ".git")) {
dirs.push(path.clone());
scan_tree(&path, out, dirs);
}
}
Ok(t) if t.is_symlink() && path.is_dir() => {}
Ok(_) => out.push(path),
Err(_) => {}
}
}
}
pub fn watched_files(root: &Path) -> std::collections::HashSet<PathBuf> {
let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let cache_base = {
let base = oj_cache::cache_base(&root);
std::fs::canonicalize(&base).unwrap_or(base)
};
let mut files = Vec::new();
scan_watched(&root, &cache_base, &mut files, &mut Vec::new());
files.into_iter().collect()
}
fn scan_watched(root: &Path, cache_base: &Path, files: &mut Vec<PathBuf>, dirs: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(root).into_iter().flatten().flatten() {
let path = entry.path();
if is_unwatched_dir(&entry.file_name()) || path == cache_base {
continue;
}
if path.is_dir() {
dirs.push(path.clone());
scan_tree(&path, files, dirs);
} else {
files.push(path);
}
}
}
pub(crate) type Snapshot = std::collections::HashMap<PathBuf, Stamp>;
pub(crate) fn boot_snapshot(ignored: &[glob::Pattern], root: &Path, cache_base: &Path) -> Snapshot {
let began = SystemTime::now();
let (mut files, mut dirs) = (Vec::new(), Vec::new());
scan_watched(root, cache_base, &mut files, &mut dirs);
files
.into_iter()
.chain(dirs)
.filter(|p| !watch_excluded(ignored, root, cache_base, p))
.filter_map(|p| Some((p.clone(), stamp(&p)?)))
.filter(|(_, s)| s.mtime < began)
.collect()
}
pub(crate) fn rescan_roots(
ignored: &[glob::Pattern],
root: &Path,
cache_base: &Path,
ev: ¬ify::Event,
) -> Vec<PathBuf> {
if !ev.need_rescan() {
return Vec::new();
}
if ev.paths.is_empty() {
return vec![root.to_path_buf()];
}
ev.paths
.iter()
.filter_map(|p| {
if root.starts_with(p) {
Some(root.to_path_buf())
} else if watched_subtree(ignored, root, cache_base, p) {
Some(p.clone())
} else {
None
}
})
.collect()
}
pub(crate) fn collapse_rescans(mut roots: Vec<PathBuf>) -> Vec<PathBuf> {
roots.sort();
roots.dedup();
let mut kept: Vec<PathBuf> = Vec::new();
for r in roots {
if !kept.iter().any(|k| r.starts_with(k)) {
kept.retain(|k| !k.starts_with(&r));
kept.push(r);
}
}
kept
}
#[derive(Debug, Default, PartialEq)]
pub(crate) struct Rescanned {
pub(crate) added: Vec<PathBuf>,
pub(crate) changed: Vec<PathBuf>,
pub(crate) removed: Vec<PathBuf>,
pub(crate) new_dirs: Vec<PathBuf>,
}
pub(crate) fn rescan_changes(
roots: &[PathBuf],
ignored: &[glob::Pattern],
root: &Path,
cache_base: &Path,
known: &Snapshot,
) -> Rescanned {
let mut out = Rescanned::default();
let excluded = |p: &Path| watch_excluded(ignored, root, cache_base, p);
for r in roots {
let (mut files, mut dirs) = (Vec::new(), Vec::new());
if r == root {
scan_watched(root, cache_base, &mut files, &mut dirs);
} else {
dirs.push(r.clone());
scan_tree(r, &mut files, &mut dirs);
}
for d in dirs.into_iter().filter(|d| !excluded(d)) {
let Some(now) = stamp(&d) else { continue };
if !known.get(&d).is_some_and(|seen| seen.same_dir(&now)) {
out.new_dirs.push(d);
}
}
for f in files.into_iter().filter(|f| !excluded(f)) {
match known.get(&f) {
None => out.added.push(f),
Some(seen) if stamp(&f) != Some(*seen) => out.changed.push(f),
Some(_) => {}
}
}
}
out.removed = known
.keys()
.filter(|p| roots.iter().any(|r| p.starts_with(r)) && !p.exists())
.cloned()
.collect();
out.new_dirs = collapse_rescans(out.new_dirs);
for list in [&mut out.added, &mut out.changed, &mut out.removed] {
list.sort();
}
out
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Stamp {
pub(crate) mtime: SystemTime,
pub(crate) ino: u64,
pub(crate) len: u64,
pub(crate) born: Option<SystemTime>,
}
impl Stamp {
fn same_dir(&self, other: &Stamp) -> bool {
self.ino == other.ino && self.born == other.born
}
}
pub(crate) fn stamp(path: &Path) -> Option<Stamp> {
let meta = std::fs::metadata(path).ok()?;
#[cfg(unix)]
let ino = std::os::unix::fs::MetadataExt::ino(&meta);
#[cfg(not(unix))]
let ino = 0;
Some(Stamp {
mtime: meta.modified().unwrap_or(UNIX_EPOCH),
ino,
len: if meta.is_dir() { 0 } else { meta.len() },
born: meta.created().ok(),
})
}
#[derive(Default)]
struct Batch {
paths: std::collections::HashSet<PathBuf>,
created: std::collections::HashSet<PathBuf>,
rescans: Vec<PathBuf>,
rescan_reason: Option<String>,
}
impl Batch {
fn add(&mut self, changes: &mut ContentChanges, ev: ¬ify::Event) -> bool {
let changed = changes.changed_paths(ev);
if matches!(ev.kind, notify::EventKind::Create(_)) {
self.created.extend(changed.iter().cloned());
}
let any = !changed.is_empty();
self.paths.extend(changed);
any
}
}
fn handle_batch(
state: &Arc<ServerState>,
paths: &[PathBuf],
created: &std::collections::HashSet<PathBuf>,
) {
if let Some(file) = paths
.iter()
.filter(|_| state.hmr_enabled)
.find(|p| is_restart_trigger(p) || is_config_dependency(p))
{
let shown = STARTUP_CWD
.get()
.and_then(|cwd| pathdiff::diff_paths(file, cwd))
.unwrap_or_else(|| file.clone());
restart_process(&format!("{} changed", shown.display()));
}
if paths.iter().any(|p| is_tsconfig_file(p)) {
oj_compiler::tsconfig::clear_cache();
state.mtime_keys.lock().unwrap().clear();
state.memory.lock().unwrap().clear();
let _ = state
.reload_tx
.send(full_reload_frame("tsconfig change", None, None));
}
if !paths.is_empty() {
state.tailwind_sheets.invalidate();
crate::hmr::drop_resolver_misses(state, paths, created);
}
let dispatch = state.hmr_enabled
&& !state
.hmr_gate
.as_ref()
.is_some_and(|gate| gate.hold(state, paths, false));
let (state, paths, created) = (Arc::clone(state), paths.to_vec(), created.clone());
state.rt.clone().spawn(async move {
run_watch_change(&state, &paths, &created).await;
invalidate_changed(&state, &paths);
if !dispatch {
return;
}
let messages = decide(&state, &paths, &created).await;
if messages.is_empty() {
return;
}
state.dir_cache.lock().unwrap().clear();
for message in messages {
let _ = state.reload_tx.send(message);
}
});
}
fn invalidate_changed(state: &ServerState, paths: &[PathBuf]) {
for path in paths {
if !path.exists() {
continue;
}
let url = url_of(&state.root, path);
let hard = state
.graph
.lock()
.unwrap()
.invalidate_on_change(Path::new(&url), |m| !is_style_url(&m.to_string_lossy()));
let mut keys = state.mtime_keys.lock().unwrap();
for module in hard {
keys.remove(&module.display().to_string());
}
}
}
pub(crate) fn spawn_watcher(
state: Arc<ServerState>,
rx: std::sync::mpsc::Receiver<WatchMsg>,
ready: tokio::sync::watch::Sender<bool>,
) {
std::thread::spawn(move || {
use std::sync::mpsc::RecvTimeoutError;
let tx = state.watch_tx.clone();
let feed = state.watch_feed.clone();
let cache_base = {
let base = oj_cache::cache_base(&state.root);
std::fs::canonicalize(&base).unwrap_or(base)
};
let (ignored, root, cache) = (
state.watch_ignored.clone(),
state.root.clone(),
cache_base.clone(),
);
let sink: EventSink = Arc::new(move |ev: notify::Result<notify::Event>| {
if let Ok(ev) = &ev {
feed.publish(ev, &ignored, &root, &cache);
}
let _ = tx.send(WatchMsg::Fs(ev));
});
let root_sink = Arc::clone(&sink);
let mut watchers = match new_watcher(move |ev| root_sink(ev)) {
Ok(root) => Watchers {
root,
sink,
own: Default::default(),
},
Err(err) => {
eprintln!("oj: file watcher failed to start: {err}");
return;
}
};
let mut served_dirs: std::collections::HashSet<PathBuf> = Default::default();
if let Err(err) = watch_root(&mut watchers.root, &state.root, &cache_base) {
eprintln!("oj: cannot watch {}: {err}", state.root.display());
return;
}
let _ = ready.send(true);
let debounce_ms: u64 = oj_env::get().knobs.hmr_debounce_ms.unwrap_or(10);
let mut seen_paths = boot_snapshot(&state.watch_ignored, &state.root, &cache_base);
let mut changes = ContentChanges::new();
loop {
let first = match rx.recv() {
Ok(WatchMsg::Fs(Ok(ev))) => ev,
Ok(WatchMsg::Fs(Err(_))) => continue,
Ok(WatchMsg::Dir(dir)) => {
watch_served_dir(&mut watchers, &mut served_dirs, dir);
continue;
}
Ok(WatchMsg::Close(ack)) => {
drop(watchers);
let _ = ack.send(());
return;
}
Err(_) => break,
};
let mut batch = Batch::default();
ingest_event(
&state,
&mut watchers,
&cache_base,
&mut changes,
&mut batch,
&first,
);
if batch.paths.is_empty() && batch.rescans.is_empty() {
continue;
}
let debounce = Duration::from_millis(debounce_ms);
let mut quiet_at = std::time::Instant::now() + debounce;
loop {
let left = quiet_at.saturating_duration_since(std::time::Instant::now());
match rx.recv_timeout(left) {
Ok(WatchMsg::Fs(Ok(ev))) => {
if ingest_event(
&state,
&mut watchers,
&cache_base,
&mut changes,
&mut batch,
&ev,
) {
quiet_at = std::time::Instant::now() + debounce;
}
}
Ok(WatchMsg::Fs(Err(_))) => {}
Ok(WatchMsg::Dir(dir)) => {
watch_served_dir(&mut watchers, &mut served_dirs, dir)
}
Ok(WatchMsg::Close(ack)) => {
drop(watchers);
let _ = ack.send(());
return;
}
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => return,
}
}
if !batch.rescans.is_empty() {
rescan(
&state,
&mut watchers,
&cache_base,
&mut changes,
&mut batch,
&seen_paths,
);
}
let Batch {
paths, mut created, ..
} = batch;
let paths: Vec<PathBuf> = paths.into_iter().collect();
created.retain(|p| !seen_paths.contains_key(p));
for p in &paths {
match stamp(p) {
Some(at) => seen_paths.insert(p.clone(), at),
None => seen_paths.remove(p),
};
}
handle_batch(&state, &paths, &created);
}
});
}
fn ingest_event(
state: &ServerState,
watchers: &mut Watchers,
cache_base: &Path,
changes: &mut ContentChanges,
batch: &mut Batch,
ev: ¬ify::Event,
) -> bool {
let rescans = rescan_roots(&state.watch_ignored, &state.root, cache_base, ev);
if !rescans.is_empty() {
batch.rescans.extend(rescans);
if let Some(info) = ev.info() {
batch.rescan_reason.get_or_insert_with(|| info.to_string());
}
return true;
}
let mut kept = false;
let adopted = adopt_created_dirs(watchers, &state.watch_ignored, &state.root, cache_base, ev);
if !adopted.is_empty() {
let mut synth =
notify::Event::new(notify::EventKind::Create(notify::event::CreateKind::Any));
synth.paths = adopted;
state
.watch_feed
.publish(&synth, &state.watch_ignored, &state.root, cache_base);
kept |= add_watched(state, cache_base, changes, batch, &synth);
}
add_watched(state, cache_base, changes, batch, ev) || kept
}
fn rescan(
state: &ServerState,
watchers: &mut Watchers,
cache_base: &Path,
changes: &mut ContentChanges,
batch: &mut Batch,
known: &Snapshot,
) {
let roots = collapse_rescans(std::mem::take(&mut batch.rescans));
let reason = batch.rescan_reason.take();
for r in &roots {
match &reason {
Some(why) => eprintln!(
"oj: file watcher lost events ({why}), rescanning {}",
r.display()
),
None => eprintln!("oj: file watcher lost events, rescanning {}", r.display()),
}
}
let mut found = rescan_changes(&roots, &state.watch_ignored, &state.root, cache_base, known);
if cfg!(target_os = "linux") {
for d in &found.new_dirs {
if let Err(err) = watchers.watch(d, notify::RecursiveMode::Recursive) {
eprintln!("oj: cannot watch {}: {err}", d.display());
}
let mut late = Vec::new();
scan_files(d, &mut late);
found
.added
.extend(late.into_iter().filter(|f| !known.contains_key(f)));
}
found.added.sort();
found.added.dedup();
}
for (kind, paths) in [
(
notify::EventKind::Create(notify::event::CreateKind::Any),
found.added,
),
(
notify::EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Any,
)),
found.changed,
),
(
notify::EventKind::Remove(notify::event::RemoveKind::Any),
found.removed,
),
] {
if paths.is_empty() {
continue;
}
let mut synth = notify::Event::new(kind);
synth.paths = paths;
state
.watch_feed
.publish(&synth, &state.watch_ignored, &state.root, cache_base);
add_watched(state, cache_base, changes, batch, &synth);
}
}
fn add_watched(
state: &ServerState,
cache_base: &Path,
changes: &mut ContentChanges,
batch: &mut Batch,
ev: ¬ify::Event,
) -> bool {
let excluded = |p: &PathBuf| watch_excluded(&state.watch_ignored, &state.root, cache_base, p);
if !ev.paths.iter().any(excluded) {
return batch.add(changes, ev);
}
let mut kept = ev.clone();
kept.paths.retain(|p| !excluded(p));
!kept.paths.is_empty() && batch.add(changes, &kept)
}