use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use crate::ast::Value;
use crate::interpreter::ExecResult;
use crate::scheduler::{JobId, JobManager};
use crate::tools::{schema_from_clap, ExecContext, GlobalFlags, Tool, ToolArgs, ToolCtx, ToolSchema};
pub struct Kill;
#[derive(Parser, Debug)]
#[command(name = "kill", about = "Send a signal to a process or job")]
struct KillArgs {
#[arg(short = 's', long, default_value_t = String::from("TERM"))]
signal: String,
#[arg(long)]
no_wait: bool,
#[command(flatten)]
global: GlobalFlags,
targets: Vec<String>,
}
const KNOWN_SIGNAL_NAMES: &[&str] =
&["TERM", "KILL", "INT", "HUP", "STOP", "CONT", "QUIT", "USR1", "USR2"];
fn normalize_signal_name(raw: &str) -> Option<&'static str> {
let upper = raw.to_ascii_uppercase();
let stripped = upper.strip_prefix("SIG").unwrap_or(&upper);
KNOWN_SIGNAL_NAMES.iter().copied().find(|&n| n == stripped)
}
#[async_trait]
impl Tool for Kill {
fn name(&self) -> &str {
"kill"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&KillArgs::command(),
"kill",
"Send a signal to a process or job",
[
("Terminate a job", "kill %1"),
("Kill a process by PID", "kill --signal KILL 1234"),
("Send signal 9 by number", "kill -9 %1"),
("Send STOP by name", "kill -STOP %1"),
],
)
.with_raw_argv()
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let argv = match args.to_argv() {
Ok(v) => v,
Err(e) => return ExecResult::failure(2, format!("kill: {e}")),
};
let parsed = match KillArgs::try_parse_from(
std::iter::once("kill".to_string()).chain(argv),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("kill: {e}")),
};
parsed.global.apply(ctx);
let mut no_wait = false;
let mut explicit_signal: Option<String> = None;
let mut shorthand_signal: Option<String> = None;
let mut targets: Vec<Value> = Vec::new();
let mut past_dash_dash = false;
let mut i = 0;
while i < args.positional.len() {
let value = &args.positional[i];
if past_dash_dash {
targets.push(value.clone());
i += 1;
continue;
}
match value {
Value::Int(n) if *n < 0 => {
shorthand_signal = Some((-n).to_string());
}
Value::String(s) => match s.as_str() {
"--" => past_dash_dash = true,
"--json" => {}
"--no-wait" => no_wait = true,
"-s" | "--signal" => {
i += 1;
let Some(next) = args.positional.get(i) else {
return ExecResult::failure(2, "kill: --signal requires a value".to_string());
};
explicit_signal = match crate::interpreter::value_to_text_sink_named(
next,
"a --signal value",
) {
Ok(v) => Some(v),
Err(e) => return ExecResult::failure(2, format!("kill: {e}")),
};
}
_ if s.starts_with("--signal=") => {
explicit_signal = Some(s["--signal=".len()..].to_string());
}
_ if s.starts_with("--json=") => {}
_ if s.starts_with('-') && s.len() > 1 => {
let bare = s.trim_start_matches('-');
if let Some(canonical) = normalize_signal_name(bare) {
shorthand_signal = Some(canonical.to_string());
} else if let Some(rest) = s.strip_prefix("-s").filter(|r| !r.is_empty()) {
explicit_signal = Some(rest.to_string());
} else {
return ExecResult::failure(
2,
format!(
"kill: unrecognized option {s} — kill accepts --signal/-s \
NAME or a signal shorthand (-TERM/-KILL/-INT/\
-HUP/-STOP/-CONT/-QUIT/-USR1/-USR2, case-insensitive, \
optional SIG prefix) or -<N> (e.g. -9); see `help kill`"
),
);
}
}
_ => targets.push(value.clone()),
},
_ => targets.push(value.clone()),
}
i += 1;
}
if explicit_signal.is_some() && shorthand_signal.is_some() {
return ExecResult::failure(
2,
"kill: signal specified twice (--signal/-s and a shorthand) — use one form"
.to_string(),
);
}
let signal_name = explicit_signal.or(shorthand_signal).unwrap_or(parsed.signal);
if targets.is_empty() {
return ExecResult::failure(
1,
"kill: usage: kill [--signal SIG | -SIG | -N] target...".to_string(),
);
}
let mut out = String::new();
let mut any_failed = false;
for target in &targets {
let target_str = match target {
Value::String(s) => s.clone(),
Value::Int(i) => i.to_string(),
other => {
any_failed = true;
out.push_str(&format!("kill: invalid target: {other:?}\n"));
continue;
}
};
let result = kill_one(ctx, &target_str, &signal_name, no_wait).await;
if result.code != 0 {
any_failed = true;
}
let text = result.text_out();
let text = text.trim_end();
if !text.is_empty() {
out.push_str(text);
out.push('\n');
}
if !result.err.is_empty() {
out.push_str(result.err.trim_end());
out.push('\n');
}
}
let out = out.trim_end().to_string();
if any_failed {
ExecResult::failure(1, out)
} else {
ExecResult::success(out)
}
}
}
async fn kill_one(
ctx: &ExecContext,
target_str: &str,
signal_name: &str,
no_wait: bool,
) -> ExecResult {
if let Some(job_num) = target_str.strip_prefix('%') {
let job_id = match job_num.parse::<u64>() {
Ok(i) => JobId(i),
Err(_) => {
return ExecResult::failure(1, format!("kill: invalid job reference: {target_str}"))
}
};
let manager = match &ctx.job_manager {
Some(m) => m.clone(),
None => return ExecResult::failure(1, "kill: no job manager"),
};
if let Some(status) = already_finished(&manager, job_id).await {
return ExecResult::success(format!(
"kill: job {job_id} already finished ({status})"
));
}
return kill_job(&manager, job_id, signal_name, no_wait).await;
}
kill_pid(target_str, signal_name)
}
async fn already_finished(manager: &JobManager, job_id: JobId) -> Option<String> {
if manager.try_result(job_id).await.is_some() {
manager.get_status_string(job_id).await
} else {
None
}
}
async fn terminate_and_confirm(
manager: &JobManager,
job_id: JobId,
no_wait: bool,
delivery: &str,
delivered: bool,
) -> ExecResult {
if !manager.mark_killed_and_cancel(job_id, delivered).await {
return ExecResult::failure(
1,
format!(
"kill: job {job_id} has no cancellation token and no live process \
group — nothing to deliver {delivery} to"
),
);
}
if no_wait {
return ExecResult::success(format!(
"kill: job {job_id}: {delivery} dispatched; not awaited (--no-wait) — the \
job stays in jobs until it unwinds"
));
}
let bound = manager.kill_grace() + std::time::Duration::from_secs(3);
match tokio::time::timeout(bound, manager.wait(job_id)).await {
Ok(Some(_)) => {
let status = manager
.get_status_string(job_id)
.await
.unwrap_or_else(|| "killed".to_string());
ExecResult::success(format!("kill: job {job_id} exited ({status})"))
}
Ok(None) => ExecResult::failure(
1,
format!(
"kill: job {job_id}: {delivery} dispatched, but the job is stopped or no \
longer tracked — resume it (bg %{job_id}) or check jobs"
),
),
Err(_) => ExecResult::failure(
1,
format!(
"kill: job {job_id}: {delivery} dispatched, but the job has not exited \
after {bound:?} — check jobs; wait %{job_id} returns its result when it dies"
),
),
}
}
#[cfg(not(all(unix, feature = "subprocess")))]
fn signal_is_terminating(name: &str) -> Option<bool> {
if let Ok(num) = name.parse::<i32>() {
return match num {
1 | 2 | 3 | 6 | 9 | 15 => Some(true),
n if n > 0 => Some(false),
_ => None,
};
}
match normalize_signal_name(name)? {
"TERM" | "KILL" | "INT" | "HUP" | "QUIT" => Some(true),
"STOP" | "CONT" | "USR1" | "USR2" => Some(false),
other => unreachable!(
"normalize_signal_name returned {other:?}, outside its own KNOWN_SIGNAL_NAMES table"
),
}
}
#[cfg(not(all(unix, feature = "subprocess")))]
async fn kill_job(
manager: &JobManager,
job_id: JobId,
signal_name: &str,
no_wait: bool,
) -> ExecResult {
match signal_is_terminating(signal_name) {
Some(true) => {
if !manager.exists(job_id).await {
return ExecResult::failure(1, format!("kill: job {job_id} not found"));
}
terminate_and_confirm(manager, job_id, no_wait, "termination", false).await
}
Some(false) => ExecResult::failure(
1,
format!(
"kill: job {job_id} is an in-process task; only termination signals \
(TERM/KILL/INT/HUP/QUIT) can be delivered, not {signal_name} \
(arbitrary-signal delivery needs the subprocess capability)"
),
),
None => ExecResult::failure(1, format!("kill: unknown signal: {signal_name}")),
}
}
#[cfg(not(all(unix, feature = "subprocess")))]
fn kill_pid(target: &str, _signal_name: &str) -> ExecResult {
ExecResult::failure(
1,
format!("kill: {target}: signalling a PID requires the subprocess capability"),
)
}
#[cfg(all(unix, feature = "subprocess"))]
async fn kill_job(
manager: &JobManager,
job_id: JobId,
signal_name: &str,
no_wait: bool,
) -> ExecResult {
use nix::sys::signal::Signal;
let signal = match parse_signal(signal_name) {
Some(s) => s,
None => return ExecResult::failure(1, format!("kill: unknown signal: {signal_name}")),
};
let terminating = matches!(
signal,
Signal::SIGTERM | Signal::SIGKILL | Signal::SIGINT | Signal::SIGHUP | Signal::SIGQUIT
);
if !manager.exists(job_id).await {
return ExecResult::failure(1, format!("kill: job {job_id} not found"));
}
let stopped = matches!(
manager.get(job_id).await.map(|info| info.status),
Some(crate::scheduler::JobStatus::Stopped)
);
let pgids = manager.job_pgids(job_id).await;
if !pgids.is_empty() {
let mut delivered_any = false;
let mut hard_err = None;
for pg in &pgids {
let pgid = nix::unistd::Pid::from_raw(*pg as i32);
match nix::sys::signal::killpg(pgid, signal) {
Ok(()) => delivered_any = true,
Err(nix::errno::Errno::ESRCH) => {}
Err(e) => hard_err = Some(e),
}
}
if let Some(e) = hard_err {
return ExecResult::failure(1, format!("kill: {e}"));
}
let groups = pgids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ");
if terminating && stopped {
for pg in &pgids {
let pgid = nix::unistd::Pid::from_raw(*pg as i32);
let _ = nix::sys::signal::killpg(pgid, Signal::SIGCONT);
}
manager.remove(job_id).await;
return ExecResult::success(if delivered_any {
format!(
"kill: job {job_id} (stopped): delivered {signal} and CONT to \
process group(s) {groups}; job untracked"
)
} else {
format!(
"kill: job {job_id} (stopped): process group(s) {groups} already \
gone; job untracked"
)
});
}
if terminating {
let delivery = format!("{signal} to process group(s) {groups}");
return terminate_and_confirm(manager, job_id, no_wait, &delivery, delivered_any)
.await;
}
if delivered_any {
return ExecResult::success(format!(
"kill: job {job_id}: delivered {signal} to process group(s) {groups}"
));
}
return ExecResult::failure(
1,
format!(
"kill: job {job_id}: recorded process group(s) {groups} have already \
exited — nothing to deliver {signal} to"
),
);
}
if terminating {
terminate_and_confirm(manager, job_id, no_wait, "termination", false).await
} else {
ExecResult::failure(
1,
format!(
"kill: job {job_id} is an in-process task with no process group; \
only termination signals (TERM/KILL/INT/HUP/QUIT) can be delivered, not {signal_name}"
),
)
}
}
#[cfg(all(unix, feature = "subprocess"))]
fn kill_pid(target: &str, signal_name: &str) -> ExecResult {
let signal = match parse_signal(signal_name) {
Some(s) => s,
None => return ExecResult::failure(1, format!("kill: unknown signal: {signal_name}")),
};
let pid_num: i32 = match target.parse() {
Ok(p) => p,
Err(_) => return ExecResult::failure(1, format!("kill: invalid pid: {target}")),
};
let pid = nix::unistd::Pid::from_raw(pid_num);
if let Err(e) = nix::sys::signal::kill(pid, signal) {
return ExecResult::failure(1, format!("kill: ({pid_num}): {e}"));
}
ExecResult::success("")
}
#[cfg(all(unix, feature = "subprocess"))]
fn parse_signal(name: &str) -> Option<nix::sys::signal::Signal> {
use nix::sys::signal::Signal;
if let Ok(num) = name.parse::<i32>() {
return Signal::try_from(num).ok();
}
match normalize_signal_name(name)? {
"TERM" => Some(Signal::SIGTERM),
"KILL" => Some(Signal::SIGKILL),
"STOP" => Some(Signal::SIGSTOP),
"CONT" => Some(Signal::SIGCONT),
"INT" => Some(Signal::SIGINT),
"HUP" => Some(Signal::SIGHUP),
"USR1" => Some(Signal::SIGUSR1),
"USR2" => Some(Signal::SIGUSR2),
"QUIT" => Some(Signal::SIGQUIT),
other => unreachable!(
"normalize_signal_name returned {other:?}, outside its own KNOWN_SIGNAL_NAMES table"
),
}
}
#[cfg(test)]
mod tests {
use super::normalize_signal_name;
#[test]
fn normalize_signal_name_is_case_insensitive() {
assert_eq!(normalize_signal_name("kill"), Some("KILL"));
assert_eq!(normalize_signal_name("Kill"), Some("KILL"));
assert_eq!(normalize_signal_name("KILL"), Some("KILL"));
}
#[test]
fn normalize_signal_name_accepts_optional_sig_prefix() {
assert_eq!(normalize_signal_name("SIGKILL"), Some("KILL"));
assert_eq!(normalize_signal_name("sigstop"), Some("STOP"));
assert_eq!(normalize_signal_name("STOP"), Some("STOP"));
}
#[test]
fn normalize_signal_name_rejects_unknown_names() {
assert_eq!(normalize_signal_name("ABRT"), None);
assert_eq!(normalize_signal_name("TSTP"), None);
assert_eq!(normalize_signal_name("WINCH"), None);
assert_eq!(normalize_signal_name("FOOBAR"), None);
}
}