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:
parent
63ab0bbd21
commit
028c283ff9
8 changed files with 244 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue