use std::io;
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context as _, bail};
use argonauts_core::Paths;
use argonauts_core::agents::AgentGroup;
use argonauts_core::layout::{run_files, session_files};
use nix::errno::Errno;
use nix::sys::signal::{SIGKILL, SIGTERM, Signal, killpg};
use nix::unistd::{Pid, getpgid};
use crate::ancestry::{Processes, agents_dir};
const TERM_GRACE: Duration = Duration::from_secs(2);
const POLL: Duration = Duration::from_millis(25);
pub(crate) async fn kill_orphans(run_dir: &Path, home: &Paths) -> anyhow::Result<Vec<u32>> {
let run_dir = run_dir.to_path_buf();
let home = home.clone();
tokio::task::spawn_blocking(move || kill_now(&run_dir, &home))
.await
.context("stopping agents a crashed run host left")?
}
fn kill_now(run_dir: &Path, home: &Paths) -> anyhow::Result<Vec<u32>> {
let mut processes = Processes::new();
let groups = orphan_groups(run_dir, &mut processes)?;
for group in &groups {
signal(group, SIGTERM)?;
}
let deadline = Instant::now() + TERM_GRACE;
while Instant::now() < deadline
&& groups
.iter()
.any(|group| still_running(group, &mut processes))
{
std::thread::sleep(POLL);
}
let mut killed = Vec::new();
for group in groups {
match signal(&group, SIGKILL) {
Err(_) if processes.start_time(group.pid) != Some(group.start_time) => {}
result => result?,
}
forget_agent(home, &group);
killed.push(group.pgid);
}
Ok(killed)
}
fn orphan_groups(run_dir: &Path, processes: &mut Processes) -> anyhow::Result<Vec<AgentGroup>> {
let sessions = run_dir.join(run_files::SESSIONS);
let entries = match std::fs::read_dir(&sessions) {
Ok(entries) => entries,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err).with_context(|| format!("reading {}", sessions.display())),
};
let own_group = getpgid(None).ok();
let mut groups = Vec::new();
for entry in entries.flatten() {
let path = entry.path().join(session_files::PROCESS);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())),
};
let group: AgentGroup = match serde_json::from_slice(&bytes) {
Ok(group) => group,
Err(err) => {
tracing::warn!("skipping {}: {err}", path.display());
continue;
}
};
let Some(pgid) = group_pid(&group).filter(|pgid| pgid.as_raw() > 1) else {
continue;
};
if Some(pgid) == own_group {
continue;
}
let ours = match processes.start_time(group.pid) {
Some(started) if started == group.start_time => i32::try_from(group.pid)
.is_ok_and(|pid| getpgid(Some(Pid::from_raw(pid))).ok() == Some(pgid)),
Some(_) => false,
None => processes.start_time(group.pgid).is_none() && has_members(pgid),
};
if ours {
groups.push(group);
} else if processes.start_time(group.pid) == Some(group.start_time) {
tracing::warn!(
"not killing process group {} from {}: it isn't the recorded agent's group",
group.pgid,
path.display()
);
}
}
Ok(groups)
}
fn still_running(group: &AgentGroup, processes: &mut Processes) -> bool {
processes.start_time(group.pid) == Some(group.start_time)
|| group_pid(group).is_some_and(has_members)
}
fn group_pid(group: &AgentGroup) -> Option<Pid> {
i32::try_from(group.pgid).ok().map(Pid::from_raw)
}
fn has_members(pgid: Pid) -> bool {
killpg(pgid, None).is_ok()
}
fn signal(group: &AgentGroup, signal: Signal) -> anyhow::Result<()> {
let pgid = group.pgid;
let Some(target) = group_pid(group) else {
bail!("process group {pgid} isn't one a signal can reach");
};
match killpg(target, signal) {
Ok(()) | Err(Errno::ESRCH) => Ok(()),
Err(err) => bail!("sending {signal} to process group {pgid}: {err}"),
}
}
fn forget_agent(home: &Paths, group: &AgentGroup) {
let path = agents_dir(home).join(format!("{}.json", group.pgid));
let Ok(bytes) = std::fs::read(&path) else {
return;
};
if serde_json::from_slice::<AgentGroup>(&bytes).is_ok_and(|entry| entry == *group)
&& let Err(err) = std::fs::remove_file(&path)
{
tracing::warn!("removing {}: {err}", path.display());
}
}
#[cfg(test)]
mod tests {
use argonauts_core::layout::session_dir;
use argonauts_core::names::SessionKey;
use super::*;
use crate::ancestry::tests::{child_of, register_group, spawn_group};
fn record(run_dir: &Path, session: u32, group: &AgentGroup) {
let dir = session_dir(run_dir, &SessionKey::numbered(session));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(session_files::PROCESS),
serde_json::to_vec(group).unwrap(),
)
.unwrap();
}
fn group_of(pid: u32) -> AgentGroup {
AgentGroup {
pgid: pid,
pid,
start_time: Processes::new().start_time(pid).unwrap(),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn orphans_are_killed_by_start_time_only() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
let run_dir = dir.path().join("run");
let mut orphan = spawn_group("sleep 30; true", &[]);
let orphan_child = child_of(orphan.id());
let orphan_group = group_of(orphan.id());
record(&run_dir, 1, &orphan_group);
register_group(&home, orphan.id());
let mut stranger = spawn_group("sleep 30; true", &[]);
let mut reused = group_of(stranger.id());
reused.start_time += 1;
record(&run_dir, 2, &reused);
let mut gone = std::process::Command::new("true").spawn().unwrap();
let gone_pid = gone.id();
gone.wait().unwrap();
record(
&run_dir,
3,
&AgentGroup {
pgid: gone_pid,
pid: gone_pid,
start_time: 1,
},
);
let killed = kill_orphans(&run_dir, &home).await.unwrap();
assert_eq!(killed, vec![orphan.id()]);
assert!(orphan.wait().unwrap().code().is_none(), "ended by a signal");
assert!(dies(orphan_child), "the whole group dies");
assert!(
!agents_dir(&home)
.join(format!("{}.json", orphan.id()))
.exists()
);
assert!(
Processes::new().start_time(stranger.id()).is_some(),
"a pid with another start time is someone else's"
);
let _ = killpg(Pid::from_raw(stranger.id() as i32), SIGKILL);
let _ = stranger.wait();
}
fn dies(pid: u32) -> bool {
let deadline = Instant::now() + Duration::from_secs(5);
while Processes::new().start_time(pid).is_some() {
if Instant::now() > deadline {
return false;
}
std::thread::sleep(POLL);
}
true
}
fn group_left_behind() -> (u32, u32) {
use std::io::BufRead as _;
use std::os::unix::process::CommandExt as _;
let mut shell = std::process::Command::new("sh")
.args(["-c", "sleep 30 & echo $!"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.process_group(0)
.spawn()
.unwrap();
let mut line = String::new();
std::io::BufReader::new(shell.stdout.take().unwrap())
.read_line(&mut line)
.unwrap();
shell.wait().unwrap();
(shell.id(), line.trim().parse().unwrap())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_group_whose_first_process_exited_is_still_killed() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
let run_dir = dir.path().join("run");
let (shell, member) = group_left_behind();
let group = AgentGroup {
pgid: shell,
pid: shell,
start_time: 1,
};
record(&run_dir, 1, &group);
let killed = kill_orphans(&run_dir, &home).await.unwrap();
assert_eq!(killed, vec![shell]);
assert!(dies(member), "the build the agent left behind stops too");
}
#[tokio::test]
async fn no_sessions_means_nothing_to_kill() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
assert!(
kill_orphans(&dir.path().join("run"), &home)
.await
.unwrap()
.is_empty()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn groups_that_ignore_sigterm_share_one_grace() {
let dir = tempfile::tempdir().unwrap();
let home = Paths::new(dir.path().join("home"));
let run_dir = dir.path().join("run");
let mut stubborn: Vec<_> = (1..=3)
.map(|session| {
let child = spawn_group("trap '' TERM; sleep 30; true", &[]);
child_of(child.id());
record(&run_dir, session, &group_of(child.id()));
child
})
.collect();
let began = Instant::now();
let killed = kill_orphans(&run_dir, &home).await.unwrap();
let took = began.elapsed();
assert_eq!(killed.len(), 3);
assert!(took >= TERM_GRACE, "each got its SIGTERM grace: {took:?}");
assert!(
took < TERM_GRACE * 2,
"one grace, not one per group: {took:?}"
);
for child in &mut stubborn {
assert!(child.wait().unwrap().code().is_none(), "ended by SIGKILL");
}
}
}