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(crate) fn restart_process(reason: &str) -> ! {
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_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);
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);
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);
}
}
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);
}
}
pub(crate) enum WatchMsg {
Fs(notify::Result<notify::Event>),
Dir(PathBuf),
}
#[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(
watcher: &mut notify::RecommendedWatcher,
watched: &mut std::collections::HashSet<PathBuf>,
dir: PathBuf,
) {
use notify::{RecursiveMode, Watcher};
if !watched.contains(&dir) && watcher.watch(&dir, RecursiveMode::NonRecursive).is_ok() {
watched.insert(dir);
}
}
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)
}
}
fn adopt_created_root_dirs(
watcher: &mut notify::RecommendedWatcher,
root: &Path,
cache_base: &Path,
ev: ¬ify::Event,
) -> Vec<PathBuf> {
use notify::{RecursiveMode, Watcher};
if !matches!(
ev.kind,
notify::EventKind::Create(_)
| notify::EventKind::Modify(notify::event::ModifyKind::Name(_))
) {
return Vec::new();
}
let mut found = Vec::new();
for path in &ev.paths {
if path.parent() != Some(root)
|| path == cache_base
|| path.file_name().is_none_or(is_unwatched_dir)
|| !path.is_dir()
{
continue;
}
if watcher.watch(path, RecursiveMode::Recursive).is_ok() {
scan_files(path, &mut found);
}
}
found
}
fn scan_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if !is_unwatched_dir(&entry.file_name()) {
scan_files(&path, out);
}
} else {
out.push(path);
}
}
}
#[derive(Default)]
struct Batch {
paths: std::collections::HashSet<PathBuf>,
created: std::collections::HashSet<PathBuf>,
}
impl Batch {
fn add(&mut self, changes: &mut ContentChanges, ev: ¬ify::Event) {
let changed = changes.changed_paths(ev);
if matches!(ev.kind, notify::EventKind::Create(_)) {
self.created.extend(changed.iter().cloned());
}
self.paths.extend(changed);
}
}
fn handle_batch(
state: &Arc<ServerState>,
paths: &[PathBuf],
created: &std::collections::HashSet<PathBuf>,
) {
if paths
.iter()
.any(|p| is_restart_trigger(p) || is_config_dependency(p))
{
restart_process("config/env changed");
}
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 !state.hmr_enabled {
return;
}
if let Some(gate) = &state.hmr_gate {
if gate.hold(state, paths, false) {
return;
}
}
let messages = state.rt.block_on(decide(state, paths, created));
if messages.is_empty() {
return;
}
state.dir_cache.lock().unwrap().clear();
for message in messages {
let _ = state.reload_tx.send(message);
}
}
pub(crate) fn spawn_watcher(state: Arc<ServerState>, rx: std::sync::mpsc::Receiver<WatchMsg>) {
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 mut watcher =
match notify::recommended_watcher(move |ev: notify::Result<notify::Event>| {
if let Ok(ev) = &ev {
feed.publish(ev, &ignored, &root, &cache);
}
let _ = tx.send(WatchMsg::Fs(ev));
}) {
Ok(w) => w,
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 watcher, &state.root, &cache_base) {
eprintln!("oj: cannot watch {}: {err}", state.root.display());
return;
}
let debounce_ms: u64 = oj_env::get().knobs.hmr_debounce_ms.unwrap_or(10);
let mut seen_paths: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
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 watcher, &mut served_dirs, dir);
continue;
}
Err(_) => break,
};
let mut batch = Batch::default();
ingest_event(
&state,
&mut watcher,
&cache_base,
&mut changes,
&mut batch,
&first,
);
if batch.paths.is_empty() {
continue;
}
loop {
match rx.recv_timeout(Duration::from_millis(debounce_ms)) {
Ok(WatchMsg::Fs(Ok(ev))) => ingest_event(
&state,
&mut watcher,
&cache_base,
&mut changes,
&mut batch,
&ev,
),
Ok(WatchMsg::Fs(Err(_))) => {}
Ok(WatchMsg::Dir(dir)) => watch_served_dir(&mut watcher, &mut served_dirs, dir),
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => return,
}
}
let Batch { paths, mut created } = batch;
let paths: Vec<PathBuf> = paths
.into_iter()
.filter(|p| !watch_excluded(&state.watch_ignored, &state.root, &cache_base, p))
.collect();
if paths.is_empty() {
continue;
}
created.retain(|p| !seen_paths.contains(p));
seen_paths.extend(paths.iter().cloned());
handle_batch(&state, &paths, &created);
}
});
}
fn ingest_event(
state: &ServerState,
watcher: &mut notify::RecommendedWatcher,
cache_base: &Path,
changes: &mut ContentChanges,
batch: &mut Batch,
ev: ¬ify::Event,
) {
let adopted = adopt_created_root_dirs(watcher, &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);
batch.add(changes, &synth);
}
batch.add(changes, ev);
}