Watch
0
0
Fork
You've already forked hyperhive
0

claude runtime: interrupting a session kills every process it started

hive-claude 0.1.3 spawns claude as the leader of its own process group,
and its `Cancel::cancel` and idle watchdog signal that group instead of
claude's pid alone. Commands claude started (a `nix build`, a test run)
used to survive a subagent `interrupt` and keep running.

Bump hive-claude to 0.1.3. The subagent `interrupt` call site needs no
code change: `Cancel::cancel(force)` kept its signature.

The harness's `/cancel` and `/logout` did not go through hive-claude:
they ran `kill -INT <pid>` on the claude child they found in /proc. They
now SIGINT the group that child leads, through `libc::kill`. A `claude`
child that leads no group (the `claude plugin` calls) shares the
harness's group and is still signalled alone.

Tests: an interrupted subagent's grandchild dies, for both SIGINT and
`force`, and fails on hive-claude 0.1.2; a harness cancel stops a
turn's grandchild, and fails when only the pid is signalled.
This commit is contained in:
atlas 2026-10-10 13:44:10 +02:00 • committed by mara
commit 028c283ff9
8 changed files with 244 additions and 24 deletions

View file

@ -3731,6 +3731,100 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// A stand-in for claude with a command of its own still running: an
/// inner `sh` writes its pid to `pidfile` and execs `sleep`. It runs in
/// the foreground because a non-interactive shell starts `&` jobs with
/// SIGINT ignored, and its output goes to /dev/null so it cannot hold the
/// driver's pipes open.
fn busy_claude(dir: &Path, pidfile: &Path) -> PathBuf {
let script = dir.join("busy-claude");
write_executable(
&script,
&format!(
"#!/bin/sh\nsh -c 'echo $$ >\"$1\"; exec sleep 300' sh '{}' >/dev/null 2>&1\n",
pidfile.display()
),
);
script
}
/// 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;
}
!std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| {
stat.rsplit_once(") ")
.is_some_and(|(_, rest)| rest.starts_with('Z'))
})
}
#[tokio::test]
async fn an_interrupt_stops_the_commands_the_subagent_started() {
for force in [false, true] {
let dir = std::env::temp_dir().join(format!(
"hive-subagent-group-{}-{:?}-{force}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).expect("scratch dir");
let pidfile = dir.join("grandchild.pid");
let state = Arc::new(mid_run("n", "carry on indefinitely", 5));
let config = Config {
program: Some(busy_claude(&dir, &pidfile)),
..Config::default()
};
spawn_and_track(
&state,
"n",
&config,
&Attach::Create("n".to_owned()),
"turn one".to_owned(),
None,
)
.expect("the fake claude spawns");
let grandchild = read_pid(&pidfile).await;
interrupt(&state, "n", force).expect("a running turn is interruptible");
let mut alive = is_running(grandchild);
for _ in 0..250 {
if !alive {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
alive = is_running(grandchild);
}
if alive {
// SAFETY: `kill(2)` on a pid this test started; keeps a failed
// run from leaving a 300s `sleep` behind.
unsafe {
libc::kill(grandchild, libc::SIGKILL);
}
}
std::fs::remove_dir_all(&dir).ok();
assert!(
!alive,
"grandchild {grandchild} outlived interrupt(force = {force})"
);
}
}
#[test]
fn a_cancelled_run_stops_with_turns_still_on_its_clock() {
// The loop half of the same fact, asserted directly: `Cancelled` is