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
6 changed files with 149 additions and 7 deletions

View file

@ -30,8 +30,15 @@ pub(super) enum Incoming {
type Reply = Result<Value, AcpError>;
type Pending = Arc<Mutex<HashMap<u64, (&'static str, oneshot::Sender<Reply>)>>>;
/// The agent runs as the leader of its own process group; dropping the
/// connection `SIGKILL`s that group, so the commands the agent started die
/// with it. A descendant that left the group (`setsid`, `setpgid`) is not
/// reached.
pub(super) struct Connection {
child: Child,
/// The agent's pid, which is its group's id. Read at spawn: a reaped
/// child has no id, and the group can outlive its leader.
pgid: Option<i32>,
stdin: Arc<tokio::sync::Mutex<ChildStdin>>,
next_id: AtomicU64,
pending: Pending,
@ -58,12 +65,14 @@ impl Connection {
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.kill_on_drop(true)
.spawn()
.map_err(|source| AcpError::Spawn {
program: command.command.clone(),
source,
})?;
let pgid = group_id(child.id());
let (Some(stdin), Some(stdout), Some(stderr)) =
(child.stdin.take(), child.stdout.take(), child.stderr.take())
else {
@ -106,6 +115,7 @@ impl Connection {
Ok(Self {
child,
pgid,
stdin,
next_id: AtomicU64::new(1),
pending,
@ -159,6 +169,25 @@ impl Connection {
}
}
/// The process group a leader with `pid` heads, as `kill(2)` takes it.
/// Never 0: `kill(-0, …)` signals the caller's own group.
fn group_id(pid: Option<u32>) -> Option<i32> {
pid.and_then(|pid| i32::try_from(pid).ok())
.filter(|&pgid| pgid > 0)
}
impl Drop for Connection {
fn drop(&mut self) {
if let Some(pgid) = self.pgid {
// SAFETY: plain `kill(2)`; `-pgid` targets the agent's own
// process group, which `process_group(0)` made it lead.
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
}
}
/// The stdout side: routes responses to their waiting request, answers the
/// agent's own requests, and queues its notifications.
struct Reader {
@ -309,7 +338,7 @@ pub(super) fn permission_outcome(
#[cfg(test)]
mod tests {
use super::permission_outcome;
use super::{group_id, permission_outcome};
use crate::acp::{PermissionAsk, PermissionPolicy};
use serde_json::json;
use std::sync::Arc;
@ -406,4 +435,12 @@ mod tests {
json!({ "outcome": { "outcome": "cancelled" } })
);
}
#[test]
fn a_group_id_is_a_positive_pid() {
assert_eq!(group_id(Some(42)), Some(42));
assert_eq!(group_id(Some(0)), None);
assert_eq!(group_id(Some(u32::MAX)), None);
assert_eq!(group_id(None), None);
}
}