web_ui: scope /cancel and /logout's SIGINT to the harness's own claude child

This commit is contained in:
damocles 2026-08-02 13:04:12 +02:00 committed by mara
commit 3617578341
3 changed files with 79 additions and 24 deletions

View file

@ -273,15 +273,76 @@ fn read_gui_vnc_port() -> Option<u16> {
std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok()
}
/// SIGINT any running `claude` process in this container (best-effort). Shared
/// by `/api/cancel` and `/api/logout`. Returns the `pkill` `Output` so callers
/// can inspect the exit status (0 = signalled, 1 = no process matched) or
/// ignore it.
async fn sigint_claude() -> std::io::Result<std::process::Output> {
tokio::process::Command::new("pkill")
.args(["-INT", "claude"])
.output()
/// Outcome of [`sigint_claude`] — shared by `/api/cancel` and `/api/logout`
/// so both can render the same three cases.
enum SigintOutcome {
/// The tracked pid was signalled.
Signalled,
/// No claude child is currently tracked (turn not in flight, or it
/// finished/exited between the check and the signal — same "nothing to
/// interrupt" outcome either way).
NoProcess,
/// The `kill` syscall itself failed for a reason other than "no such
/// process" (permission issue, etc).
Failed(std::io::Error),
}
/// Find a `claude` process that is a direct OS child of this harness
/// process, if any. `hive-claude`'s driver spawns the turn's claude
/// directly (`Command::new(program).spawn()`, no shell in between), so the
/// harness is always the immediate parent of any claude turn it started —
/// scanning `/proc/*/status` for `Name: claude` + `PPid: <our own pid>`
/// finds *that* specific process without needing the driver to surface its
/// pid through any extra plumbing. Distinguishes the harness's own tracked
/// turn from an unrelated `claude` someone is running interactively in the
/// same container (a manually shelled-in "choom" session) — that one's
/// parent is a login shell, not us. Best-effort: a process that exits
/// mid-scan (its `/proc/<pid>/status` read fails, ESRCH) is just skipped.
fn find_claude_child() -> Option<u32> {
let own_pid = std::process::id();
for entry in std::fs::read_dir("/proc").ok()?.flatten() {
let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else {
continue; // not a pid dir (self, cwd, net, ...)
};
let Ok(status) = std::fs::read_to_string(entry.path().join("status")) else {
continue;
};
let mut name = None;
let mut parent_pid = None;
for line in status.lines() {
if let Some(v) = line.strip_prefix("Name:") {
name = Some(v.trim());
} else if let Some(v) = line.strip_prefix("PPid:") {
parent_pid = v.trim().parse::<u32>().ok();
}
}
if name == Some("claude") && parent_pid == Some(own_pid) {
return Some(pid);
}
}
None
}
/// SIGINT this container's harness-spawned claude child, if any
/// (best-effort). See [`find_claude_child`] for how it's identified —
/// **not** a name-based `pkill claude`, which would also hit an unrelated
/// `claude` process someone is running interactively in the container.
async fn sigint_claude() -> SigintOutcome {
let Some(pid) = find_claude_child() else {
return SigintOutcome::NoProcess;
};
match tokio::process::Command::new("kill")
.args(["-INT", &pid.to_string()])
.status()
.await
{
Ok(status) if status.success() => SigintOutcome::Signalled,
// Non-zero from `kill` means "no such process" (ESRCH) — it already
// exited between the check above and the signal. Same as never
// having found it.
Ok(_) => SigintOutcome::NoProcess,
Err(e) => SigintOutcome::Failed(e),
}
}
fn error_response(status: StatusCode, message: &str) -> Response {