//! Process management: detached background start, PID bookkeeping, stop //! with a graceful signal followed by a forceful kill after a timeout. use crate::app::App; use anyhow::{bail, Context, Result}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; /// Log file for a background agent. pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf { app.log.log_dir().join(format!("{agent_name}.log")) } /// Is a process with this PID alive? pub fn is_running(pid: u32) -> bool { #[cfg(windows)] { let out = Command::new("tasklist") .args(["/FI", &format!("PID eq {pid}"), "/NH"]) .output(); match out { Ok(o) => String::from_utf8_lossy(&o.stdout) .split_whitespace() .any(|w| w == pid.to_string()), Err(_) => false, } } #[cfg(not(windows))] { Command::new("kill") .args(["-0", &pid.to_string()]) .status() .map(|s| s.success()) .unwrap_or(false) } } /// Send SIGTERM/SIGKILL (or taskkill / taskkill /F on Windows). fn signal(pid: u32, force: bool) -> bool { #[cfg(windows)] { let mut cmd = Command::new("taskkill"); cmd.arg("/PID").arg(pid.to_string()); if force { cmd.arg("/T").arg("/F"); } cmd.status().map(|s| s.success()).unwrap_or(false) } #[cfg(not(windows))] { let sig = if force { "-KILL" } else { "-TERM" }; Command::new("kill") .arg(sig) .arg(pid.to_string()) .status() .map(|s| s.success()) .unwrap_or(false) } } /// Stop a process: TERM first, then KILL after the timeout. Returns whether /// the process was running when the call started. pub fn stop_pid(pid: u32, force: bool, timeout_secs: u64) -> bool { if !is_running(pid) { return false; } if force { signal(pid, true); } else { signal(pid, false); let deadline = Instant::now() + Duration::from_secs(timeout_secs); while Instant::now() < deadline { if !is_running(pid) { return true; } std::thread::sleep(Duration::from_millis(200)); } if is_running(pid) { signal(pid, true); } } for _ in 0..50 { if !is_running(pid) { return true; } std::thread::sleep(Duration::from_millis(100)); } !is_running(pid) } /// Kill a process and its whole tree (process group on unix, taskkill /T on /// Windows). Used by the plugin runner and the lab benchmark to stop /// runaway scripts and timed-out task runs. pub fn kill_tree(pid: u32) { #[cfg(unix)] { use std::os::unix::process::CommandExt; let _ = Command::new("kill") .arg("-KILL") .arg(format!("-{pid}")) // negative pid = the process group .status(); let _ = Command::new("kill").arg("-KILL").arg(pid.to_string()).status(); } #[cfg(windows)] { let _ = Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .status(); } } /// Start a program detached: stdout/stderr are redirected to the agent log /// file. Returns the PID. Fails when the process exits immediately. pub fn spawn_background( app: &App, agent_name: &str, program: &Path, args: &[String], env: &BTreeMap, cwd: Option<&Path>, ) -> Result { let log = agent_log_path(app, agent_name); if app.dry_run() { app.log.dry(format!( "would start {} (detached, log: {})", program.display(), log.display() )); return Ok(0); } if let Some(parent) = log.parent() { std::fs::create_dir_all(parent)?; } let file = std::fs::File::create(&log) .with_context(|| format!("cannot create log file {}", log.display()))?; let (prog, prefix) = crate::runner::resolve_program(&program.display().to_string()); let mut full_args = prefix; full_args.extend(args.iter().cloned()); let mut cmd = Command::new(&prog); cmd.args(&full_args) .envs(env) .stdout(Stdio::from(file.try_clone()?)) .stderr(Stdio::from(file)); if let Some(dir) = cwd { cmd.current_dir(dir); } #[cfg(unix)] { use std::os::unix::process::CommandExt; cmd.process_group(0); } #[cfg(windows)] { use std::os::windows::process::CommandExt; const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); } app.log .cmd(&format!("{} {}", program.display(), args.join(" "))); let mut child = cmd .spawn() .with_context(|| format!("failed to start {}", program.display()))?; let pid = child.id(); // Give the process a moment: some agents crash at startup. std::thread::sleep(Duration::from_millis(400)); match child.try_wait() { Ok(Some(status)) => { let tail = last_lines(&log, 8); bail!( "agent '{agent_name}' exited immediately after start (status {status}); log: {} {}", log.display(), tail ); } Ok(None) => {} Err(_) => {} } Ok(pid) } /// Tail of a text file (last n lines). pub fn last_lines(path: &Path, n: usize) -> String { let Ok(text) = std::fs::read_to_string(path) else { return String::new(); }; let lines: Vec<&str> = text.lines().collect(); let start = lines.len().saturating_sub(n); lines[start..].join(" ") } /// Best-effort desktop notification once an agent has started. pub fn desktop_notify(title: &str, body: &str) { #[cfg(target_os = "linux")] { let _ = Command::new("notify-send") .args(["-a", "agent-manager", title, body]) .spawn(); } #[cfg(target_os = "macos")] { let _ = Command::new("osascript") .args([ "-e", &format!("display notification \"{body}\" with title \"{title}\""), ]) .spawn(); } #[cfg(windows)] { // No built-in non-blocking toast without extra tooling; log instead. let _ = (title, body); } } #[cfg(test)] mod tests { use super::*; fn sleep_cmd() -> (&'static str, Vec) { if cfg!(windows) { ("ping", vec!["-n".to_string(), "30".to_string(), "127.0.0.1".to_string()]) } else { ("sleep", vec!["30".to_string()]) } } #[test] fn pid_lifecycle() { let (prog, args) = sleep_cmd(); let mut child = Command::new(prog).args(&args).spawn().unwrap(); let pid = child.id(); assert!(is_running(pid)); assert!(stop_pid(pid, true, 5)); assert!(!is_running(pid)); let _ = child.wait(); } #[test] fn stop_non_running_is_false() { assert!(!stop_pid(u32::MAX - 1, true, 1)); } }