Watch
0
0
Fork
You've already forked hyperhive
0

hive-runtime: kill an ACP agent's whole process group, not just its pid

The ACP agent was spawned with only `kill_on_drop`, which SIGKILLs the
agent's own pid. Commands the agent had started (a `nix build`, a test
run) were reparented and kept running after the agent was killed: when a
subagent run ends after `interrupt`, when an agent ignores
`session/cancel` past the grace, and when the harness drops the runtime.

The agent now leads its own process group (`process_group(0)`), and
dropping its `Connection` SIGKILLs that group. The soft path is unchanged:
`interrupt` / `Canceller::cancel` still sends `session/cancel`.

Refs #4999. The claude half needs hive-claude 0.1.3, which is not on
crates.io yet.
This commit is contained in:
atlas 2026-10-09 23:57:07 +02:00
commit 8d206f124f

View file

@ -1062,6 +1062,10 @@ mod tests {
const AGENT: &str = r#"
n=0 prompt= model=m/think effort=low
printf 'start\n' >> "$1.methods"
# With GRANDCHILD set: a background `sleep`, its pid in <log>.grandchild.
if [ -n "$GRANDCHILD" ]; then
sh -c 'echo $$ >"$1.grandchild"; exec sleep 300' sh "$1" >/dev/null 2>&1 &
fi
update() {
printf '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"%s","update":%s}}\n' "$1" "$2"
}
@ -1430,6 +1434,101 @@ done
assert_eq!(count(dir.path(), "start"), 2);
}
/// Poll `path` until it holds a complete, newline-terminated pid.
async fn read_pid(path: &Path) -> i32 {
for _ in 0..250 {
if let Ok(text) = std::fs::read_to_string(path)
&& text.ends_with('\n')
{
return text.trim().parse().expect("pid file holds a pid");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
unreachable!("no pid in {} within 5s", path.display());
}
/// Whether `pid` is still running. A zombie counts as gone: it has
/// exited, and `kill(pid, 0)` only keeps finding it until it is reaped.
fn is_running(pid: i32) -> bool {
// SAFETY: signal 0 delivers nothing; it only checks that `pid` exists.
if unsafe { libc::kill(pid, 0) } != 0 {
return false;
}
// Field 3 of /proc/<pid>/stat, right after the parenthesised comm, is
// the state letter. Without /proc the process counts as running.
!std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| {
stat.rsplit_once(") ")
.is_some_and(|(_, rest)| rest.starts_with('Z'))
})
}
/// Require `pid` to be gone within 5s, killing it if it is not.
async fn assert_gone(pid: i32) {
let mut alive = is_running(pid);
for _ in 0..250 {
if !alive {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
alive = is_running(pid);
}
if alive {
// SAFETY: `kill(2)` on a pid this test started; keeps a failed
// run from leaving a 300s `sleep` behind.
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
assert!(!alive, "grandchild {pid} outlived the agent");
}
#[tokio::test]
async fn an_agent_ignoring_a_cancel_is_killed_with_the_commands_it_started() {
let dir = tempfile::tempdir().unwrap();
let mut runtime = agent(
dir.path(),
"deaf",
&[("GRANDCHILD", "1")],
PercentPolicy::default(),
);
runtime.cancel_grace = Duration::from_millis(200);
let (canceller, config) = (runtime.canceller().unwrap(), config(dir.path()));
let (done, grandchild) = tokio::join!(runtime.run(&config, "one", &NoopSink), async {
prompt_sent(dir.path()).await;
let grandchild = read_pid(&dir.path().join("prompts.grandchild")).await;
assert!(canceller.cancel());
grandchild
});
assert!(
matches!(done, Err(Error::Acp(AcpError::CancelIgnored { .. }))),
"{done:?}"
);
assert_gone(grandchild).await;
}
/// A subagent's run drops its runtime when it ends, an interrupted one
/// included.
#[tokio::test]
async fn dropping_the_runtime_kills_the_commands_its_agent_started() {
let dir = tempfile::tempdir().unwrap();
let runtime = agent(
dir.path(),
"ok",
&[("GRANDCHILD", "1")],
PercentPolicy::default(),
);
runtime
.run(&config(dir.path()), "one", &NoopSink)
.await
.unwrap();
let grandchild = read_pid(&dir.path().join("prompts.grandchild")).await;
assert!(is_running(grandchild));
drop(runtime);
assert_gone(grandchild).await;
}
fn sent(session: &str, text: &str) -> (String, String) {
(session.to_owned(), text.to_owned())
}