hive-bash-mcp: add kill tool to stop a running or pending bash task
This commit is contained in:
parent
e1b7f7cbec
commit
b16629801b
4 changed files with 291 additions and 60 deletions
|
|
@ -14,14 +14,15 @@
|
|||
//! The runner kills the child process on timeout — `tokio::process::Child::drop()`
|
||||
//! does not kill children, so we explicitly call `child.kill().await`.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::paths;
|
||||
use crate::protocol::{TaskFile, TaskStatus};
|
||||
|
|
@ -41,6 +42,50 @@ const POLL_MS: u64 = 100;
|
|||
|
||||
static TASK_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Running-task registry (for on-demand kill)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle to a currently-executing task, used by [`kill_task`] to signal it.
|
||||
/// `exec_cmd` selects on `cancel`; when notified it signals the task's
|
||||
/// process group with `SIGKILL` (if `force`) or `SIGINT`.
|
||||
struct RunningHandle {
|
||||
cancel: Arc<Notify>,
|
||||
force: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Global registry of running tasks (id → handle). Populated by `run_task`
|
||||
/// for the duration of execution and read by [`kill_task`] from the socket
|
||||
/// dispatch path — a separate async context from the runner loop, so a
|
||||
/// shared global (rather than the loop-local `claimed` set) is needed.
|
||||
fn running() -> &'static Mutex<HashMap<String, RunningHandle>> {
|
||||
static RUNNING: OnceLock<Mutex<HashMap<String, RunningHandle>>> = OnceLock::new();
|
||||
RUNNING.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Outcome of one `exec_cmd` run.
|
||||
enum ExecOutcome {
|
||||
/// Process exited on its own with this status code.
|
||||
Exited(i32),
|
||||
/// Killed by the per-task timeout (`timeout_secs`).
|
||||
TimedOut,
|
||||
/// Killed on request via [`kill_task`]. `forced` = SIGKILL vs SIGINT.
|
||||
Killed { forced: bool },
|
||||
}
|
||||
|
||||
/// Send `sig` to the task's process group. A negative pgid targets the whole
|
||||
/// group, so `bash -c` plus any children it spawned all receive the signal.
|
||||
/// No-op if the child had no pid (already reaped).
|
||||
fn signal_group(pgid: Option<i32>, sig: i32) {
|
||||
if let Some(pgid) = pgid {
|
||||
// SAFETY: plain libc `kill(2)`; `-pgid` targets the child's own
|
||||
// process group (it called `setpgid(0, 0)` so it leads its group).
|
||||
unsafe {
|
||||
libc::kill(-pgid, sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -232,7 +277,10 @@ pub async fn wait_for_task(id: &str, wait_secs: u64) -> Option<TaskFile> {
|
|||
Some(task) => {
|
||||
if matches!(
|
||||
task.status,
|
||||
TaskStatus::Done | TaskStatus::TimedOut | TaskStatus::Interrupted
|
||||
TaskStatus::Done
|
||||
| TaskStatus::TimedOut
|
||||
| TaskStatus::Interrupted
|
||||
| TaskStatus::Killed
|
||||
) {
|
||||
return Some(task);
|
||||
}
|
||||
|
|
@ -246,6 +294,42 @@ pub async fn wait_for_task(id: &str, wait_secs: u64) -> Option<TaskFile> {
|
|||
read_task(id)
|
||||
}
|
||||
|
||||
/// Kill a running or still-pending task.
|
||||
///
|
||||
/// - **Running** (in the registry): signals its process group via the
|
||||
/// `exec_cmd` cancel path — `SIGKILL` if `force`, else `SIGINT`. The task
|
||||
/// transitions to [`TaskStatus::Killed`] once the process exits and a
|
||||
/// completion wake fires as usual. A `SIGINT` relies on the process
|
||||
/// honouring it; pass `force` for a guaranteed stop.
|
||||
/// - **Pending** (queued, not yet started): marked `Killed` directly so the
|
||||
/// runner loop never starts it. No process exists yet, so `force` is moot.
|
||||
/// - **Terminal or unknown id**: no-op.
|
||||
///
|
||||
/// Returns `(killed, was_running)`: `killed` = a kill was issued (signal sent
|
||||
/// or pending task cancelled); `was_running` = the task was actively running.
|
||||
pub fn kill_task(id: &str, force: bool) -> (bool, bool) {
|
||||
// Running task: signal via the registry.
|
||||
{
|
||||
let guard = running().lock().unwrap();
|
||||
if let Some(handle) = guard.get(id) {
|
||||
handle.force.store(force, Ordering::SeqCst);
|
||||
handle.cancel.notify_one();
|
||||
return (true, true);
|
||||
}
|
||||
}
|
||||
// Pending task: mark Killed so the poll loop never starts it.
|
||||
if let Some(mut task) = read_task(id)
|
||||
&& task.status == TaskStatus::Pending
|
||||
{
|
||||
task.status = TaskStatus::Killed;
|
||||
task.completed_at = Some(now_unix());
|
||||
let _ = write_task(&task);
|
||||
refresh_loose_ends();
|
||||
return (true, false);
|
||||
}
|
||||
(false, false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runner background loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -358,27 +442,57 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
|
|||
let out_path = paths::task_out(&id);
|
||||
let err_path = paths::task_err(&id);
|
||||
|
||||
let (timed_out, exit_code) =
|
||||
match exec_cmd(&task.cmd, &out_path, &err_path, task.timeout_secs).await {
|
||||
Ok((code, false)) => (false, Some(code)),
|
||||
Ok((_, true)) => {
|
||||
tracing::warn!(id = %id, "bash_runner: task timed out");
|
||||
(true, None)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(id = %id, error = ?e, "bash_runner: exec error");
|
||||
(false, None)
|
||||
}
|
||||
};
|
||||
// Register a kill handle for the duration of execution so `kill_task`
|
||||
// (called from the socket dispatch path) can signal this task.
|
||||
let cancel = Arc::new(Notify::new());
|
||||
let force = Arc::new(AtomicBool::new(false));
|
||||
running().lock().unwrap().insert(
|
||||
id.clone(),
|
||||
RunningHandle {
|
||||
cancel: cancel.clone(),
|
||||
force: force.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let outcome = exec_cmd(
|
||||
&task.cmd,
|
||||
&out_path,
|
||||
&err_path,
|
||||
task.timeout_secs,
|
||||
&cancel,
|
||||
&force,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Deregister before writing terminal state — the task is no longer killable.
|
||||
running().lock().unwrap().remove(&id);
|
||||
|
||||
let stdout_tail = tail_file(&out_path, SUMMARY_BYTES);
|
||||
let stderr_tail = tail_file(&err_path, SUMMARY_BYTES);
|
||||
|
||||
task.status = if timed_out {
|
||||
TaskStatus::TimedOut
|
||||
} else {
|
||||
TaskStatus::Done
|
||||
let (status, exit_code, summary) = match outcome {
|
||||
Ok(ExecOutcome::Exited(code)) => (TaskStatus::Done, Some(code), format!("exit={code}")),
|
||||
Ok(ExecOutcome::TimedOut) => {
|
||||
tracing::warn!(id = %id, "bash_runner: task timed out");
|
||||
let secs = task.timeout_secs.unwrap_or(0);
|
||||
(
|
||||
TaskStatus::TimedOut,
|
||||
None,
|
||||
format!("timed out after {secs}s"),
|
||||
)
|
||||
}
|
||||
Ok(ExecOutcome::Killed { forced }) => {
|
||||
let sig = if forced { "SIGKILL" } else { "SIGINT" };
|
||||
tracing::warn!(id = %id, sig, "bash_runner: task killed");
|
||||
(TaskStatus::Killed, None, format!("killed ({sig})"))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(id = %id, error = ?e, "bash_runner: exec error");
|
||||
(TaskStatus::Done, None, "exec error".to_owned())
|
||||
}
|
||||
};
|
||||
|
||||
task.status = status;
|
||||
task.completed_at = Some(now_unix());
|
||||
task.exit_code = exit_code;
|
||||
task.stdout_tail = stdout_tail.clone().filter(|s| !s.is_empty());
|
||||
|
|
@ -389,33 +503,31 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
|
|||
}
|
||||
refresh_loose_ends();
|
||||
|
||||
let summary = if timed_out {
|
||||
let secs = task.timeout_secs.unwrap_or(0);
|
||||
format!("timed out after {secs}s")
|
||||
} else {
|
||||
format!("exit={}", exit_code.unwrap_or(-1))
|
||||
};
|
||||
let out_snippet = stdout_tail.as_deref().unwrap_or("").trim();
|
||||
let err_snippet = stderr_tail.as_deref().unwrap_or("").trim();
|
||||
send_wake(socket, &id, &summary, Some((out_snippet, err_snippet))).await;
|
||||
}
|
||||
|
||||
/// Run `bash -c cmd`, streaming output to files. Returns `(exit_code, timed_out)`.
|
||||
/// Run `bash -c cmd` in its own process group, streaming output to files.
|
||||
/// `timeout_secs = None` means no timeout — run until natural exit.
|
||||
///
|
||||
/// Tasks run under `bash`, not `sh`: on NixOS `/bin/sh` is bash in POSIX
|
||||
/// mode, which disables bashisms (arrays, `[[ … ]]`, `local`, process
|
||||
/// substitution, …). Agents write bash, so we invoke `bash` (via
|
||||
/// `/usr/bin/env bash`).
|
||||
/// Tasks run under `bash`, not `sh` (on NixOS `/bin/sh` is bash in POSIX
|
||||
/// mode, which disables bashisms — arrays, `[[ … ]]`, `local`, process
|
||||
/// substitution), invoked via `/usr/bin/env bash`.
|
||||
///
|
||||
/// The task is cancellable via `cancel` (set `force` first): on cancel the
|
||||
/// whole process group is signalled — `SIGKILL` if `force`, else `SIGINT` —
|
||||
/// so children of the shell die too, not just the shell. The timeout path
|
||||
/// likewise `SIGKILL`s the group.
|
||||
async fn exec_cmd(
|
||||
cmd: &str,
|
||||
out_path: &Path,
|
||||
err_path: &Path,
|
||||
timeout_secs: Option<u64>,
|
||||
) -> Result<(i32, bool)> {
|
||||
cancel: &Notify,
|
||||
force: &AtomicBool,
|
||||
) -> Result<ExecOutcome> {
|
||||
use tokio::process::Command;
|
||||
// SAFETY: `nice` is async-signal-safe and modifies only the calling
|
||||
// process's scheduling priority before exec. No allocations, no locks.
|
||||
// `/usr/bin/env bash` rather than a bare `bash`: `/usr/bin/env` is at a
|
||||
// fixed absolute path (coreutils, present on NixOS), and it resolves
|
||||
// `bash` via PATH — the same controlled PATH the daemon's systemd unit
|
||||
|
|
@ -427,13 +539,20 @@ async fn exec_cmd(
|
|||
.arg(cmd)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
// SAFETY: `setpgid`/`nice` are async-signal-safe and only touch the
|
||||
// calling (child) process before exec — no allocations, no locks.
|
||||
// `setpgid(0, 0)` makes the child its own process-group leader so the
|
||||
// whole subtree can be signalled as a group (`kill(-pgid, …)`).
|
||||
unsafe {
|
||||
cmd_builder.pre_exec(|| {
|
||||
libc::setpgid(0, 0);
|
||||
libc::nice(10);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let mut child = cmd_builder.spawn()?;
|
||||
// With `setpgid(0, 0)` the child's pgid equals its pid.
|
||||
let pgid = child.id().map(u32_to_i32);
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
|
|
@ -450,33 +569,53 @@ async fn exec_cmd(
|
|||
err_path,
|
||||
));
|
||||
|
||||
if let Some(secs) = timeout_secs {
|
||||
match tokio::time::timeout(Duration::from_secs(secs), child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
let _ = copy_out.await;
|
||||
let _ = copy_err.await;
|
||||
Ok((status.code().unwrap_or(-1), false))
|
||||
// A future that fires when the timeout elapses, or never if `None`.
|
||||
let timeout_fut = async {
|
||||
match timeout_secs {
|
||||
Some(secs) => tokio::time::sleep(Duration::from_secs(secs)).await,
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
};
|
||||
|
||||
let mut timed_out = false;
|
||||
let mut killed: Option<bool> = None;
|
||||
let wait_res = {
|
||||
let wait = child.wait();
|
||||
tokio::pin!(wait);
|
||||
tokio::pin!(timeout_fut);
|
||||
tokio::select! {
|
||||
res = &mut wait => res,
|
||||
() = &mut timeout_fut => {
|
||||
signal_group(pgid, libc::SIGKILL);
|
||||
timed_out = true;
|
||||
(&mut wait).await
|
||||
}
|
||||
Ok(Err(e)) => Err(e.into()),
|
||||
Err(_elapsed) => {
|
||||
let _ = child.kill().await;
|
||||
let _ = child.wait().await;
|
||||
let _ = copy_out.await;
|
||||
let _ = copy_err.await;
|
||||
Ok((-1, true))
|
||||
() = cancel.notified() => {
|
||||
let forced = force.load(Ordering::SeqCst);
|
||||
signal_group(pgid, if forced { libc::SIGKILL } else { libc::SIGINT });
|
||||
killed = Some(forced);
|
||||
(&mut wait).await
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = copy_out.await;
|
||||
let _ = copy_err.await;
|
||||
|
||||
let status = wait_res?;
|
||||
let outcome = if let Some(forced) = killed {
|
||||
ExecOutcome::Killed { forced }
|
||||
} else if timed_out {
|
||||
ExecOutcome::TimedOut
|
||||
} else {
|
||||
// No timeout — wait for natural exit.
|
||||
match child.wait().await {
|
||||
Ok(status) => {
|
||||
let _ = copy_out.await;
|
||||
let _ = copy_err.await;
|
||||
Ok((status.code().unwrap_or(-1), false))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
ExecOutcome::Exited(status.code().unwrap_or(-1))
|
||||
};
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// `u32` pid → `i32` for `kill(2)`. Pids fit in `i32`; saturates defensively.
|
||||
fn u32_to_i32(v: u32) -> i32 {
|
||||
i32::try_from(v).unwrap_or(i32::MAX)
|
||||
}
|
||||
|
||||
async fn copy_stream_to_file<R>(mut reader: R, path: PathBuf)
|
||||
|
|
|
|||
Loading…
Reference in a new issue