Files
agent-manager/src/process.rs
T

226 lines
6.4 KiB
Rust

//! 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)
}
/// 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<String, String>,
cwd: Option<&Path>,
) -> Result<u32> {
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<String>) {
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));
}
}