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() -> ! {
eprintln!("{} config/env changed — restarting dev server", oj_brand());
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),
}
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) -> notify::Result<()> {
use notify::{RecursiveMode, Watcher};
let mut watched_any = false;
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
if is_unwatched_dir(&entry.file_name()) {
continue;
}
let path = entry.path();
let mode = if path.is_dir() {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
if watcher.watch(&path, mode).is_ok() {
watched_any = true;
}
}
}
if watched_any {
Ok(())
} else {
watcher.watch(root, RecursiveMode::Recursive)
}
}
#[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();
}
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 mut watcher = match notify::recommended_watcher(move |ev| {
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) {
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();
batch.add(&mut changes, &first);
if batch.paths.is_empty() {
continue;
}
loop {
match rx.recv_timeout(Duration::from_millis(debounce_ms)) {
Ok(WatchMsg::Fs(Ok(ev))) => batch.add(&mut changes, &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| !is_watch_ignored(&state.watch_ignored, &state.root, 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);
}
});
}