226 lines
6.4 KiB
Rust
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));
|
|
}
|
|
}
|