use super::clean;
use console::style;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebouncedEvent};
use std::io::{self, BufRead, BufReader, IsTerminal, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
macro_rules! sprintln {
() => {{
print!("\r\n");
let _ = io::stdout().flush();
}};
($($arg:tt)*) => {{
print!("{}\r\n", format_args!($($arg)*));
let _ = io::stdout().flush();
}};
}
macro_rules! seprintln {
() => {{
eprint!("\r\n");
let _ = io::stderr().flush();
}};
($($arg:tt)*) => {{
eprint!("{}\r\n", format_args!($($arg)*));
let _ = io::stderr().flush();
}};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ReloadTrigger {
Manual,
FileChanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum KbAction {
Reload,
Quit,
}
#[allow(clippy::too_many_arguments)]
pub(super) fn render_banner(
is_watch: bool,
is_tty: bool,
backend_only: bool,
frontend_only: bool,
backend_host: &str,
backend_port: u16,
vite_port: u16,
) -> String {
use std::fmt::Write;
let mut s = String::new();
if !frontend_only {
let _ = writeln!(s, "Backend: http://{backend_host}:{backend_port}");
}
if !backend_only {
let _ = writeln!(s, "Frontend: http://127.0.0.1:{vite_port}");
}
if !frontend_only {
let _ = writeln!(s);
if is_tty {
let _ = writeln!(s, " r rebuild backend + regenerate types");
} else {
let _ = writeln!(s, " r unavailable (non-TTY stdin)");
}
let _ = writeln!(s, " q quit (or Ctrl+C)");
if is_watch {
let _ = writeln!(s, " watch enabled (debounce 500ms)");
} else {
let _ = writeln!(
s,
" watch disabled (pass --watch to auto-reload on file changes)"
);
}
}
s
}
pub(super) fn classify_key(code: KeyCode, modifiers: KeyModifiers) -> Option<KbAction> {
match (code, modifiers) {
(KeyCode::Char('r'), KeyModifiers::NONE) => Some(KbAction::Reload),
(KeyCode::Char('q'), KeyModifiers::NONE) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
Some(KbAction::Quit)
}
_ => None,
}
}
pub(super) fn format_trigger_source(t: ReloadTrigger) -> &'static str {
match t {
ReloadTrigger::Manual => "manual",
ReloadTrigger::FileChanged => "file change",
}
}
pub(super) fn should_spawn_keyboard(is_tty: bool) -> bool {
is_tty
}
#[cfg(unix)]
fn configure_new_process_group(cmd: &mut Command) {
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(not(unix))]
fn configure_new_process_group(_cmd: &mut Command) {}
fn spawn_child_with_prefix(
command: &str,
args: &[&str],
cwd: Option<&Path>,
prefix: &str,
color: console::Color,
env_vars: &[(&str, &str)],
shutdown: Arc<AtomicBool>,
) -> Result<Child, String> {
let mut cmd = Command::new(command);
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
for (key, value) in env_vars {
cmd.env(key, value);
}
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
configure_new_process_group(&mut cmd);
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn {command}: {e}"))?;
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let prefix_out = prefix.to_string();
let prefix_err = prefix.to_string();
let sd_out = shutdown.clone();
let sd_err = shutdown;
thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
if sd_out.load(Ordering::SeqCst) {
break;
}
if let Ok(line) = line {
print!("{} {}\r\n", style(&prefix_out).fg(color).bold(), line);
let _ = io::stdout().flush();
}
}
});
thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines() {
if sd_err.load(Ordering::SeqCst) {
break;
}
if let Ok(line) = line {
eprint!("{} {}\r\n", style(&prefix_err).fg(color).bold(), line);
let _ = io::stderr().flush();
}
}
});
Ok(child)
}
const GROUP_KILL_GRACE: Duration = Duration::from_millis(2000);
const GROUP_KILL_POLL: Duration = Duration::from_millis(50);
fn terminate_child_group(child: &mut Child) {
#[cfg(unix)]
{
let pid = child.id() as i32;
unsafe {
libc::kill(-pid, libc::SIGTERM);
}
let deadline = std::time::Instant::now() + GROUP_KILL_GRACE;
loop {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) => {
if std::time::Instant::now() >= deadline {
break;
}
thread::sleep(GROUP_KILL_POLL);
}
Err(_) => break,
}
}
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = child.wait();
}
#[cfg(not(unix))]
{
let _ = child.kill();
let _ = child.wait();
}
}
struct ProcessManager {
children: Vec<Child>,
shutdown: Arc<AtomicBool>,
}
impl ProcessManager {
fn new() -> Self {
Self {
children: Vec::new(),
shutdown: Arc::new(AtomicBool::new(false)),
}
}
fn spawn_with_prefix_env(
&mut self,
command: &str,
args: &[&str],
cwd: Option<&Path>,
prefix: &str,
color: console::Color,
env_vars: &[(&str, &str)],
) -> Result<(), String> {
let child = spawn_child_with_prefix(
command,
args,
cwd,
prefix,
color,
env_vars,
self.shutdown.clone(),
)?;
self.children.push(child);
Ok(())
}
fn shutdown_all(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
for child in &mut self.children {
terminate_child_group(child);
}
}
}
fn get_package_name() -> Result<String, String> {
let cargo_toml = Path::new("Cargo.toml");
let content = std::fs::read_to_string(cargo_toml)
.map_err(|e| format!("Failed to read Cargo.toml: {e}"))?;
let parsed: toml::Value = content
.parse()
.map_err(|e| format!("Failed to parse Cargo.toml: {e}"))?;
parsed
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "Could not find package name in Cargo.toml".to_string())
}
fn validate_ferro_project(backend_only: bool, frontend_only: bool) -> Result<(), String> {
let cargo_toml = Path::new("Cargo.toml");
let frontend_dir = Path::new("frontend");
if !frontend_only && !cargo_toml.exists() {
return Err("No Cargo.toml found. Are you in a Ferro project directory?".into());
}
if !backend_only && !frontend_dir.exists() {
return Err("No frontend directory found. Are you in a Ferro project directory?".into());
}
Ok(())
}
fn ensure_npm_dependencies() -> Result<(), String> {
let frontend_path = Path::new("frontend");
let node_modules = frontend_path.join("node_modules");
if !node_modules.exists() {
sprintln!("{}", style("Installing frontend dependencies...").yellow());
let npm_install = Command::new("npm")
.args(["install"])
.current_dir(frontend_path)
.status()
.map_err(|e| format!("Failed to run npm install: {e}"))?;
if !npm_install.success() {
return Err("Failed to install npm dependencies".into());
}
sprintln!(
"{}",
style("Frontend dependencies installed successfully.").green()
);
}
Ok(())
}
fn find_available_port(start: u16, max_attempts: u16) -> u16 {
for offset in 0..max_attempts {
let port = start + offset;
if TcpListener::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
start
}
struct RawModeGuard;
impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
}
}
fn spawn_keyboard_thread(
tx: Sender<ReloadTrigger>,
shutdown: Arc<AtomicBool>,
) -> Option<JoinHandle<()>> {
let is_tty = std::io::stdin().is_terminal();
if !should_spawn_keyboard(is_tty) {
return None;
}
if let Err(e) = enable_raw_mode() {
seprintln!("{} raw mode unavailable: {e}", style("Warning:").yellow());
return None;
}
Some(thread::spawn(move || {
let _guard = RawModeGuard;
while !shutdown.load(Ordering::SeqCst) {
match event::poll(Duration::from_millis(100)) {
Ok(true) => {}
_ => continue,
}
let Ok(Event::Key(k)) = event::read() else {
continue;
};
if k.kind != KeyEventKind::Press {
continue;
}
match classify_key(k.code, k.modifiers) {
Some(KbAction::Reload) => {
let _ = tx.send(ReloadTrigger::Manual);
}
Some(KbAction::Quit) => {
shutdown.store(true, Ordering::SeqCst);
break;
}
None => {}
}
}
}))
}
fn spawn_file_watcher_at(
src: &Path,
debounce: Duration,
tx: Sender<ReloadTrigger>,
) -> Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>> {
if !src.is_dir() {
seprintln!(
"{} {} missing, --watch disabled",
style("Warning:").yellow(),
src.display()
);
return None;
}
let mut debouncer = match new_debouncer(
debounce,
move |res: notify_debouncer_mini::DebounceEventResult| {
let Ok(events) = res else {
return;
};
let any_rs = events
.iter()
.any(|e: &DebouncedEvent| e.path.extension().map(|x| x == "rs").unwrap_or(false));
if any_rs {
let _ = tx.send(ReloadTrigger::FileChanged);
}
},
) {
Ok(d) => d,
Err(e) => {
seprintln!("{} notify init failed: {e}", style("Warning:").yellow());
return None;
}
};
if let Err(e) = debouncer.watcher().watch(src, RecursiveMode::Recursive) {
seprintln!(
"{} watch({}) failed: {e}",
style("Warning:").yellow(),
src.display()
);
return None;
}
Some(debouncer)
}
fn spawn_file_watcher(
tx: Sender<ReloadTrigger>,
) -> Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>> {
spawn_file_watcher_at(Path::new("src"), Duration::from_millis(500), tx)
}
struct BackendSupervisor {
package_name: String,
skip_types: bool,
project_path: PathBuf,
types_output_path: PathBuf,
current: Option<Child>,
shutdown: Arc<AtomicBool>,
}
impl BackendSupervisor {
fn new(
package_name: String,
skip_types: bool,
project_path: PathBuf,
types_output_path: PathBuf,
shutdown: Arc<AtomicBool>,
) -> Self {
Self {
package_name,
skip_types,
project_path,
types_output_path,
current: None,
shutdown,
}
}
fn kill_current(&mut self) {
if let Some(mut child) = self.current.take() {
terminate_child_group(&mut child);
}
}
fn regenerate_types(&self) {
if self.skip_types {
return;
}
match super::generate_types::generate_types_to_file(
&self.project_path,
&self.types_output_path,
) {
Ok(count) if count > 0 => {
sprintln!("{} Regenerated {} type(s)", style("[types]").blue(), count);
}
Ok(_) => {}
Err(e) => {
seprintln!("{} Failed to regenerate: {}", style("[types]").yellow(), e);
}
}
}
fn spawn_backend(&mut self) {
let args = ["run", "--bin", self.package_name.as_str()];
match spawn_child_with_prefix(
"cargo",
&args,
None,
"[backend]",
console::Color::Magenta,
&[],
self.shutdown.clone(),
) {
Ok(child) => self.current = Some(child),
Err(e) => {
seprintln!("{} {}", style("Error:").red().bold(), e);
self.current = None;
}
}
}
fn drain_triggers(rx: &Receiver<ReloadTrigger>, initial: ReloadTrigger) -> ReloadTrigger {
let mut latest = initial;
while let Ok(next) = rx.try_recv() {
latest = next;
}
latest
}
fn run_loop(&mut self, rx: Receiver<ReloadTrigger>) {
self.spawn_backend();
loop {
if self.shutdown.load(Ordering::SeqCst) {
self.kill_current();
break;
}
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(initial) => {
let src = Self::drain_triggers(&rx, initial);
sprintln!(
"{} reload triggered ({})",
style("[backend]").magenta().bold(),
format_trigger_source(src)
);
self.kill_current();
self.regenerate_types();
self.spawn_backend();
}
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => break,
}
}
}
}
pub fn run(
port: u16,
frontend_port: u16,
backend_only: bool,
frontend_only: bool,
skip_types: bool,
watch: bool,
) {
let _ = dotenvy::dotenv();
let backend_host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let backend_port = if port != 8080 {
port
} else {
std::env::var("SERVER_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8080)
};
let requested_vite_port = if frontend_port != 5173 {
frontend_port
} else {
std::env::var("VITE_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(frontend_port)
};
let vite_port = find_available_port(requested_vite_port, 10);
if vite_port != requested_vite_port {
sprintln!(
"{} Port {} in use, using {} instead",
style("[frontend]").cyan().bold(),
requested_vite_port,
vite_port
);
}
std::env::set_var("VITE_DEV_SERVER", format!("http://localhost:{vite_port}"));
let sweep_days: u32 = std::env::var("CARGO_SWEEP_DAYS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(7);
if sweep_days > 0 {
if let Some(cleaned) = clean::run_silent(sweep_days) {
sprintln!("{} {}", style("♻").cyan(), cleaned);
}
}
sprintln!();
sprintln!(
"{}",
style("Starting Ferro development servers...").cyan().bold()
);
sprintln!();
if let Err(e) = validate_ferro_project(backend_only, frontend_only) {
seprintln!("{} {}", style("Error:").red().bold(), e);
std::process::exit(1);
}
if !skip_types && !frontend_only {
let project_path = Path::new(".");
let output_path = project_path.join("frontend/src/types/inertia-props.ts");
sprintln!("{}", style("Generating TypeScript types...").cyan());
match super::generate_types::generate_types_to_file(project_path, &output_path) {
Ok(0) => {
sprintln!(
"{}",
style("No InertiaProps structs found (skipping type generation)").dim()
);
}
Ok(count) => {
sprintln!(
"{} Generated {} type(s) to {}",
style("✓").green(),
count,
output_path.display()
);
}
Err(e) => {
seprintln!(
"{} Failed to generate types: {} (continuing anyway)",
style("Warning:").yellow(),
e
);
}
}
sprintln!();
}
if !backend_only {
if let Err(e) = ensure_npm_dependencies() {
seprintln!("{} {}", style("Error:").red().bold(), e);
std::process::exit(1);
}
}
let mut manager = ProcessManager::new();
let shutdown = manager.shutdown.clone();
{
let shutdown = shutdown.clone();
ctrlc::set_handler(move || {
sprintln!();
sprintln!("{}", style("Shutting down servers...").yellow());
shutdown.store(true, Ordering::SeqCst);
})
.expect("Error setting Ctrl-C handler");
}
let is_tty = std::io::stdin().is_terminal();
let banner = render_banner(
watch,
is_tty,
backend_only,
frontend_only,
&backend_host,
backend_port,
vite_port,
);
print!("{banner}");
if !backend_only {
let frontend_path = Path::new("frontend");
let vite_port_str = vite_port.to_string();
if let Err(e) = manager.spawn_with_prefix_env(
"npm",
&["run", "dev", "--", "--port", &vite_port_str, "--strictPort"],
Some(frontend_path),
"[frontend]",
console::Color::Cyan,
&[],
) {
seprintln!("{} {}", style("Error:").red().bold(), e);
manager.shutdown_all();
std::process::exit(1);
}
}
let supervisor_handle: Option<JoinHandle<()>>;
let keyboard_handle: Option<JoinHandle<()>>;
let _debouncer: Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>>;
if !frontend_only {
let package_name = match get_package_name() {
Ok(name) => name,
Err(e) => {
seprintln!("{} {}", style("Error:").red().bold(), e);
manager.shutdown_all();
std::process::exit(1);
}
};
let project_path = Path::new(".").to_path_buf();
let types_output_path = project_path.join("frontend/src/types/inertia-props.ts");
let (reload_tx, reload_rx) = channel::<ReloadTrigger>();
keyboard_handle = spawn_keyboard_thread(reload_tx.clone(), shutdown.clone());
_debouncer = if watch {
spawn_file_watcher(reload_tx.clone())
} else {
None
};
let mut supervisor = BackendSupervisor::new(
package_name,
skip_types,
project_path,
types_output_path,
shutdown.clone(),
);
supervisor_handle = Some(thread::spawn(move || supervisor.run_loop(reload_rx)));
if let Ok(pipe_path) = std::env::var("FERRO_SERVE_TEST_TRIGGER_PIPE") {
let tx = reload_tx.clone();
let sd = shutdown.clone();
thread::spawn(move || loop {
if sd.load(Ordering::SeqCst) {
break;
}
if let Ok(content) = std::fs::read_to_string(&pipe_path) {
if !content.is_empty() {
if content.contains('r') {
let _ = tx.send(ReloadTrigger::Manual);
}
if content.contains('q') {
sd.store(true, Ordering::SeqCst);
break;
}
let _ = std::fs::write(&pipe_path, "");
}
}
thread::sleep(Duration::from_millis(50));
});
}
drop(reload_tx);
} else {
supervisor_handle = None;
keyboard_handle = None;
_debouncer = None;
}
sprintln!();
sprintln!("{}", style("Press Ctrl+C to stop all servers").dim());
sprintln!();
while !shutdown.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(100));
}
if let Some(h) = keyboard_handle {
let _ = h.join();
}
drop(_debouncer);
if let Some(h) = supervisor_handle {
let _ = h.join();
}
manager.shutdown_all();
sprintln!("{}", style("Servers stopped.").green());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_banner_matrix() {
let b_watch_off_tty = "Backend: http://127.0.0.1:8080\n\
Frontend: http://127.0.0.1:5173\n\
\n\
\x20\x20r rebuild backend + regenerate types\n\
\x20\x20q quit (or Ctrl+C)\n\
\x20\x20watch disabled (pass --watch to auto-reload on file changes)\n";
let b_watch_on_tty = "Backend: http://127.0.0.1:8080\n\
Frontend: http://127.0.0.1:5173\n\
\n\
\x20\x20r rebuild backend + regenerate types\n\
\x20\x20q quit (or Ctrl+C)\n\
\x20\x20watch enabled (debounce 500ms)\n";
let b_watch_off_non_tty = "Backend: http://127.0.0.1:8080\n\
Frontend: http://127.0.0.1:5173\n\
\n\
\x20\x20r unavailable (non-TTY stdin)\n\
\x20\x20q quit (or Ctrl+C)\n\
\x20\x20watch disabled (pass --watch to auto-reload on file changes)\n";
let b_watch_on_non_tty = "Backend: http://127.0.0.1:8080\n\
Frontend: http://127.0.0.1:5173\n\
\n\
\x20\x20r unavailable (non-TTY stdin)\n\
\x20\x20q quit (or Ctrl+C)\n\
\x20\x20watch enabled (debounce 500ms)\n";
assert_eq!(
render_banner(false, true, false, false, "127.0.0.1", 8080, 5173),
b_watch_off_tty,
);
assert_eq!(
render_banner(true, true, false, false, "127.0.0.1", 8080, 5173),
b_watch_on_tty,
);
assert_eq!(
render_banner(false, false, false, false, "127.0.0.1", 8080, 5173),
b_watch_off_non_tty,
);
assert_eq!(
render_banner(true, false, false, false, "127.0.0.1", 8080, 5173),
b_watch_on_non_tty,
);
}
#[test]
fn classify_key_table() {
assert_eq!(
classify_key(KeyCode::Char('r'), KeyModifiers::NONE),
Some(KbAction::Reload)
);
assert_eq!(classify_key(KeyCode::Char('R'), KeyModifiers::SHIFT), None);
assert_eq!(
classify_key(KeyCode::Char('q'), KeyModifiers::NONE),
Some(KbAction::Quit)
);
assert_eq!(
classify_key(KeyCode::Char('c'), KeyModifiers::CONTROL),
Some(KbAction::Quit)
);
assert_eq!(classify_key(KeyCode::Char('x'), KeyModifiers::NONE), None);
}
#[test]
fn trigger_source_formatting() {
assert_eq!(format_trigger_source(ReloadTrigger::Manual), "manual");
assert_eq!(
format_trigger_source(ReloadTrigger::FileChanged),
"file change"
);
}
#[test]
fn should_spawn_keyboard_gated_on_tty() {
assert!(should_spawn_keyboard(true));
assert!(!should_spawn_keyboard(false));
}
#[test]
fn kill_current_noop_when_none() {
let shutdown = Arc::new(AtomicBool::new(false));
let mut sup = BackendSupervisor::new(
"x".into(),
true,
PathBuf::from("."),
PathBuf::from("."),
shutdown,
);
assert!(sup.current.is_none());
sup.kill_current();
assert!(sup.current.is_none());
}
#[test]
fn supervisor_coalesces_multiple_triggers() {
let (tx, rx) = channel::<ReloadTrigger>();
tx.send(ReloadTrigger::Manual).unwrap();
tx.send(ReloadTrigger::FileChanged).unwrap();
tx.send(ReloadTrigger::Manual).unwrap();
drop(tx); let first = ReloadTrigger::Manual;
let latest = BackendSupervisor::drain_triggers(&rx, first);
assert!(matches!(latest, ReloadTrigger::Manual));
assert!(
rx.try_recv().is_err(),
"all triggers must have been drained"
);
}
#[cfg(unix)]
#[test]
fn spawn_child_with_prefix_uses_new_process_group() {
let shutdown = Arc::new(AtomicBool::new(false));
let mut child = spawn_child_with_prefix(
"sh",
&["-c", "sleep 30"],
None,
"[t]",
console::Color::Black,
&[],
shutdown,
)
.expect("spawn");
let pid = child.id() as i32;
let pgid = unsafe { libc::getpgid(pid) };
unsafe {
libc::kill(-pid, libc::SIGKILL);
}
let _ = child.wait();
assert_eq!(
pgid, pid,
"child PGID ({pgid}) must equal child PID ({pid}) after setsid"
);
}
#[cfg(unix)]
#[test]
fn terminate_child_group_reaches_grandchild() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let pid_file = tmp.path().join("gc.pid");
let script = format!("sleep 60 & echo $! > {}; wait", pid_file.display());
let shutdown = Arc::new(AtomicBool::new(false));
let mut child = spawn_child_with_prefix(
"sh",
&["-c", &script],
None,
"[t]",
console::Color::Black,
&[],
shutdown,
)
.expect("spawn");
let deadline = std::time::Instant::now() + Duration::from_secs(3);
let grandchild_pid: i32 = loop {
if let Ok(s) = std::fs::read_to_string(&pid_file) {
if let Ok(p) = s.trim().parse::<i32>() {
if p > 0 {
break p;
}
}
}
if std::time::Instant::now() > deadline {
unsafe {
libc::kill(-(child.id() as i32), libc::SIGKILL);
}
let _ = child.wait();
panic!("grandchild PID never recorded");
}
thread::sleep(Duration::from_millis(25));
};
assert_eq!(
unsafe { libc::kill(grandchild_pid, 0) },
0,
"precondition: grandchild must be alive"
);
terminate_child_group(&mut child);
let deadline = std::time::Instant::now() + Duration::from_secs(2);
loop {
if unsafe { libc::kill(grandchild_pid, 0) } != 0 {
return;
}
if std::time::Instant::now() > deadline {
panic!("grandchild {grandchild_pid} still alive after terminate_child_group");
}
thread::sleep(Duration::from_millis(25));
}
}
#[test]
fn debouncer_coalesces_burst() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let src = tmp.path().join("src");
std::fs::create_dir(&src).unwrap();
let src = std::fs::canonicalize(&src).unwrap_or(src);
let (tx, rx) = channel::<ReloadTrigger>();
let debounce = Duration::from_millis(500);
let _debouncer = spawn_file_watcher_at(&src, debounce, tx).expect("debouncer init");
let start = std::time::Instant::now();
for i in 0..10 {
std::fs::write(src.join(format!("f{i}.rs")), "fn main(){}").unwrap();
}
std::fs::write(src.join("unrelated.txt"), "x").unwrap();
let evt = rx
.recv_timeout(debounce * 6)
.expect("at least one trigger within 6× debounce window");
assert!(matches!(evt, ReloadTrigger::FileChanged));
assert!(
start.elapsed() >= debounce - Duration::from_millis(100),
"debounce window too short: {:?}",
start.elapsed()
);
let drain_deadline = std::time::Instant::now() + debounce * 2;
let mut extra = 0usize;
while let Some(remaining) = drain_deadline.checked_duration_since(std::time::Instant::now())
{
match rx.recv_timeout(remaining) {
Ok(_) => extra += 1,
Err(_) => break,
}
}
let total = 1 + extra;
assert!(
total < 11,
"debouncer failed to coalesce: {total} events for 11 writes"
);
}
}