From f054a720fa66ebd9244f0faf0441d538bd9d992c Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Mon, 27 Jul 2026 15:47:43 +0200 Subject: [PATCH 1/6] kill: implementation for Windows --- Cargo.toml | 2 +- src/uu/kill/Cargo.toml | 2 +- src/uu/kill/locales/en-US.ftl | 6 + src/uu/kill/locales/fr-FR.ftl | 7 + src/uu/kill/src/kill.rs | 64 +------ src/uu/kill/src/platform/mod.rs | 18 ++ src/uu/kill/src/platform/unix.rs | 64 +++++++ src/uu/kill/src/platform/windows.rs | 28 +++ .../src/lib/features/process/windows.rs | 144 +++++++++++++-- tests/by-util/test_kill.rs | 171 +++++++++++++++--- 10 files changed, 409 insertions(+), 97 deletions(-) create mode 100644 src/uu/kill/src/platform/mod.rs create mode 100644 src/uu/kill/src/platform/unix.rs create mode 100644 src/uu/kill/src/platform/windows.rs diff --git a/Cargo.toml b/Cargo.toml index fa2382ab69f..3abf715414d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -298,7 +298,7 @@ feat_os_unix_musl = [ "feat_require_unix_utmpx", ] # "feat_os_windows" == set of utilities which can be built/run on modern windows platforms -feat_os_windows = ["feat_Tier1", "stdbuf", "timeout"] +feat_os_windows = ["feat_Tier1", "kill", "stdbuf", "timeout"] ## (secondary platforms) feature sets # "feat_os_unix_gnueabihf" == set of utilities which can be built/run on the "arm-unknown-linux-gnueabihf" target (ARMv6 Linux [hardfloat]) feat_os_unix_gnueabihf = [ diff --git a/src/uu/kill/Cargo.toml b/src/uu/kill/Cargo.toml index 45072046e42..55badd8997f 100644 --- a/src/uu/kill/Cargo.toml +++ b/src/uu/kill/Cargo.toml @@ -21,7 +21,7 @@ doctest = false [dependencies] clap = { workspace = true } thiserror = { workspace = true } -uucore = { workspace = true, features = ["signals"] } +uucore = { workspace = true, features = ["process", "signals"] } fluent = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/src/uu/kill/locales/en-US.ftl b/src/uu/kill/locales/en-US.ftl index 61b8a4739a3..e62be4976b4 100644 --- a/src/uu/kill/locales/en-US.ftl +++ b/src/uu/kill/locales/en-US.ftl @@ -1,5 +1,11 @@ kill-about = Send signal to processes or list information about signals. kill-usage = kill [OPTIONS]... PID... +kill-after-help-windows = Windows notes: + Signalled processes are force-terminated (Windows has no signal delivery); + their exit status is 128 plus the signal number. Process groups (PID <= 0) + and STOP are not supported. Permissions come from your current token: kill + never enables SeDebugPrivilege, so an elevated kill may report 'Permission + denied' where 'taskkill /F' succeeds. # Help messages kill-help-list = Lists signals diff --git a/src/uu/kill/locales/fr-FR.ftl b/src/uu/kill/locales/fr-FR.ftl index 0e82c13da4d..3d2582cd008 100644 --- a/src/uu/kill/locales/fr-FR.ftl +++ b/src/uu/kill/locales/fr-FR.ftl @@ -1,5 +1,12 @@ kill-about = Envoyer un signal aux processus ou lister les informations sur les signaux. kill-usage = kill [OPTIONS]... PID... +kill-after-help-windows = Notes pour Windows : + Les processus signalés sont terminés de force (Windows ne délivre pas de + signaux) ; leur code de sortie est 128 plus le numéro du signal. Les groupes + de processus (PID <= 0) et STOP ne sont pas pris en charge. Les permissions + proviennent de votre jeton actuel : kill n'active jamais SeDebugPrivilege, + donc un kill élevé peut signaler « Permission denied » là où « taskkill /F » + réussit. # Messages d'aide kill-help-list = Liste les signaux diff --git a/src/uu/kill/src/kill.rs b/src/uu/kill/src/kill.rs index 7bfae0cd040..ebf0b49c51b 100644 --- a/src/uu/kill/src/kill.rs +++ b/src/uu/kill/src/kill.rs @@ -6,11 +6,6 @@ // spell-checker:ignore (ToDO) signalname pids killpg NOPESIG use clap::{Arg, ArgAction, Command}; -use rustix::process::{ - Pid, Signal, kill_current_process_group, kill_process, kill_process_group, - test_kill_current_process_group, test_kill_process, test_kill_process_group, -}; -use std::cmp::Ordering; use std::io::{self, BufWriter, Write}; use thiserror::Error; use uucore::display::Quotable; @@ -23,6 +18,8 @@ use uucore::signals::{ }; use uucore::{format_usage, show}; +mod platform; + // When the -l option is selected, the program displays the type of signal related to a certain // value or string. In case of a value, the program should control the lower 8 bits, but there is // a particular case in which if the value is in range [128, 159], it is translated to a signal @@ -95,7 +92,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } pub fn uu_app() -> Command { - Command::new("kill") + let cmd = Command::new("kill") .version(uucore::crate_version!()) .help_template(uucore::localized_help_template("kill")) .about(translate!("kill-about")) @@ -131,7 +128,10 @@ pub fn uu_app() -> Command { Arg::new(options::PIDS_OR_SIGNALS) .hide(true) .action(ArgAction::Append), - ) + ); + #[cfg(windows)] + let cmd = cmd.after_help(translate!("kill-after-help-windows")); + cmd } fn handle_obsolete(args: &mut Vec) -> UResult> { @@ -244,18 +244,6 @@ fn list(signals: &Vec) -> UResult<()> { Ok(()) } -// rustix's `Signal` rejects libc-reserved realtime signals, so fall back to a -// raw `libc::kill` for any value its safe constructor doesn't recognize. -fn raw_kill(pid: i32, sig: usize) -> io::Result<()> { - let sig = i32::try_from(sig).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; - // SAFETY: plain FFI call; `kill` has no memory-safety preconditions. - if unsafe { libc::kill(pid as libc::pid_t, sig) } == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } -} - fn parse_signal_value(signal_name: &str) -> UResult { let optional_signal_value = signal_by_name_or_value(signal_name); match optional_signal_value { @@ -281,44 +269,8 @@ fn parse_pids(pids: &[String]) -> UResult> { } fn kill(sig: usize, pids: &[i32]) { - // Standard named signals use rustix's typed API; anything its safe - // constructor doesn't recognize (realtime/reserved) falls back to libc. - let named = (sig != 0) - .then(|| i32::try_from(sig).ok().and_then(Signal::from_named_raw)) - .flatten(); for &pid in pids { - let result = match pid.cmp(&0) { - Ordering::Equal => match named { - _ if sig == 0 => test_kill_current_process_group().map_err(io::Error::from), - Some(s) => kill_current_process_group(s).map_err(io::Error::from), - None => raw_kill(0, sig), - }, - Ordering::Greater => { - let pid = Pid::from_raw(pid).expect("pid > 0 guaranteed by Ordering::Greater"); - match named { - _ if sig == 0 => test_kill_process(pid).map_err(io::Error::from), - Some(s) => kill_process(pid, s).map_err(io::Error::from), - None => raw_kill(pid.as_raw_nonzero().get(), sig), - } - } - Ordering::Less => { - let Some(abs_pid) = pid.checked_neg() else { - show!(USimpleError::new( - 1, - translate!("kill-error-sending-signal", "pid" => pid), - )); - continue; - }; - let pid = - Pid::from_raw(abs_pid).expect("abs_pid > 0 since pid < 0 and pid != i32::MIN"); - match named { - _ if sig == 0 => test_kill_process_group(pid).map_err(io::Error::from), - Some(s) => kill_process_group(pid, s).map_err(io::Error::from), - None => raw_kill(-pid.as_raw_nonzero().get(), sig), - } - } - }; - if let Err(e) = result { + if let Err(e) = platform::send_signal(pid, sig) { show!(e.map_err_context(|| translate!("kill-error-sending-signal", "pid" => pid))); } } diff --git a/src/uu/kill/src/platform/mod.rs b/src/uu/kill/src/platform/mod.rs new file mode 100644 index 00000000000..716d768d082 --- /dev/null +++ b/src/uu/kill/src/platform/mod.rs @@ -0,0 +1,18 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Platform-specific piece of `kill`: delivering one signal to one pid via +//! the [`send_signal`] facade, provided by both submodules with an identical +//! signature. The shared control flow stays in `kill.rs`. + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub(crate) use unix::*; + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub(crate) use windows::*; diff --git a/src/uu/kill/src/platform/unix.rs b/src/uu/kill/src/platform/unix.rs new file mode 100644 index 00000000000..f3c4a5f18a2 --- /dev/null +++ b/src/uu/kill/src/platform/unix.rs @@ -0,0 +1,64 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore pids ESRCH + +use std::cmp::Ordering; +use std::io; + +use rustix::process::{ + Pid, Signal, kill_current_process_group, kill_process, kill_process_group, + test_kill_current_process_group, test_kill_process, test_kill_process_group, +}; + +// rustix's `Signal` rejects libc-reserved realtime signals, so fall back to a +// raw `libc::kill` for any value its safe constructor doesn't recognize. +fn raw_kill(pid: i32, sig: usize) -> io::Result<()> { + let sig = i32::try_from(sig).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; + // SAFETY: plain FFI call; `kill` has no memory-safety preconditions. + if unsafe { libc::kill(pid as libc::pid_t, sig) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +/// Deliver `sig` to `pid` with kill(2) semantics: positive pids target one +/// process, 0 the current process group, negative pids the group `-pid`. +pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { + // Standard named signals use rustix's typed API; anything its safe + // constructor doesn't recognize (realtime/reserved) falls back to libc. + let named = (sig != 0) + .then(|| i32::try_from(sig).ok().and_then(Signal::from_named_raw)) + .flatten(); + match pid.cmp(&0) { + Ordering::Equal => match named { + _ if sig == 0 => test_kill_current_process_group().map_err(io::Error::from), + Some(s) => kill_current_process_group(s).map_err(io::Error::from), + None => raw_kill(0, sig), + }, + Ordering::Greater => { + let pid = Pid::from_raw(pid).expect("pid > 0 guaranteed by Ordering::Greater"); + match named { + _ if sig == 0 => test_kill_process(pid).map_err(io::Error::from), + Some(s) => kill_process(pid, s).map_err(io::Error::from), + None => raw_kill(pid.as_raw_nonzero().get(), sig), + } + } + Ordering::Less => { + // i32::MIN cannot be negated, so no such process group can exist. + let Some(abs_pid) = pid.checked_neg() else { + return Err(io::Error::from_raw_os_error(libc::ESRCH)); + }; + let pid = + Pid::from_raw(abs_pid).expect("abs_pid > 0 since pid < 0 and pid != i32::MIN"); + match named { + _ if sig == 0 => test_kill_process_group(pid).map_err(io::Error::from), + Some(s) => kill_process_group(pid, s).map_err(io::Error::from), + None => raw_kill(-pid.as_raw_nonzero().get(), sig), + } + } + } +} diff --git a/src/uu/kill/src/platform/windows.rs b/src/uu/kill/src/platform/windows.rs new file mode 100644 index 00000000000..a2723d7eff1 --- /dev/null +++ b/src/uu/kill/src/platform/windows.rs @@ -0,0 +1,28 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Windows implementation of `kill`'s platform facade, built on the signal +//! emulation in [`uucore::process`]. STOP (no process-suspend API) and +//! process groups (`pid <= 0`) have no Windows emulation and are rejected. + +use std::io; + +use uucore::process::send_signal_to_pid; + +const SIGNAL_STOP: usize = 19; + +fn unsupported(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::Unsupported, message) +} + +pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { + if sig == SIGNAL_STOP { + return Err(unsupported("SIGSTOP is not supported on Windows")); + } + match u32::try_from(pid) { + Ok(pid) if pid != 0 => send_signal_to_pid(pid, sig), + _ => Err(unsupported("process groups are not supported on Windows")), + } +} diff --git a/src/uucore/src/lib/features/process/windows.rs b/src/uucore/src/lib/features/process/windows.rs index da340e9dbb0..c05d7349f7d 100644 --- a/src/uucore/src/lib/features/process/windows.rs +++ b/src/uucore/src/lib/features/process/windows.rs @@ -5,9 +5,10 @@ // spell-checker:ignore (win-api) WAITABLE Waitable PHANDLER unsignaled // spell-checker:ignore (signals) CHLD TSTP TTIN TTOU WINCH ESRCH -// spell-checker:ignore catchable targetable wakeup +// spell-checker:ignore catchable targetable wakeup unreaped pids -//! Windows emulation of POSIX signal delivery for child processes. +//! Windows emulation of POSIX signal delivery, for child processes and +//! arbitrary pids ([`send_signal_to_pid`]). //! //! Windows has no signals, so this module emulates the POSIX default //! dispositions with native primitives: signal numbers follow the Linux @@ -26,8 +27,11 @@ use std::sync::OnceLock; use std::sync::atomic::{AtomicI32, Ordering}; use std::time::Duration; +use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER}; use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT}; -use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, INFINITE}; +use windows_sys::Win32::System::Threading::{ + CREATE_NEW_PROCESS_GROUP, INFINITE, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, +}; use windows_sys::core::BOOL; use super::{ChildExt, TimeoutRet}; @@ -49,9 +53,9 @@ pub mod sys { AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject, }; use windows_sys::Win32::System::Threading::{ - CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, CreateEventW, CreateWaitableTimerExW, ResetEvent, - SetEvent, SetWaitableTimer, TIMER_ALL_ACCESS, TerminateProcess, WaitForMultipleObjects, - WaitForSingleObject, + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, CreateEventW, CreateWaitableTimerExW, OpenProcess, + ResetEvent, SetEvent, SetWaitableTimer, TIMER_ALL_ACCESS, TerminateProcess, + WaitForMultipleObjects, WaitForSingleObject, }; use windows_sys::core::BOOL; @@ -117,6 +121,13 @@ pub mod sys { cvt(unsafe { TerminateProcess(process.as_raw_handle() as HANDLE, exit_code) }) } + /// Open `pid` with exactly `desired_access`; the handle is non-inheritable. + pub fn open_process(pid: u32, desired_access: u32) -> io::Result { + // SAFETY: OpenProcess returns null on failure, otherwise a fresh + // handle owned by us — the `cvt_created_handle` contract. + unsafe { cvt_created_handle(OpenProcess(desired_access, FALSE, pid)) } + } + /// Create an unnamed manual-reset event, initially unsignaled. pub fn create_manual_reset_event() -> io::Result { // SAFETY: null attributes and name are documented as valid; the @@ -301,6 +312,73 @@ pub fn send_signal_to_process(child: &Child, signal: usize) -> io::Result<()> { } } +const PROBE_ACCESS: u32 = PROCESS_SYNCHRONIZE; +// SYNCHRONIZE tells "already exited" apart from a real denial after a failed +// TerminateProcess (both report ERROR_ACCESS_DENIED). +const TERMINATE_ACCESS: u32 = PROCESS_TERMINATE | PROCESS_SYNCHRONIZE; + +fn no_such_process() -> io::Error { + io::Error::new(io::ErrorKind::NotFound, "No such process") +} + +/// A process handle becomes signaled when the process exits. +fn has_exited(handle: BorrowedHandle) -> io::Result { + Ok(matches!( + sys::wait_for_one(handle, 0)?, + sys::WaitOutcome::Object(_) + )) +} + +// OpenProcess reports dead and never-allocated pids as ERROR_INVALID_PARAMETER; +// the custom message matches unix ESRCH stderr. ERROR_ACCESS_DENIED stays raw +// (already renders "Permission denied", like unix EPERM). +fn map_open_error(error: io::Error) -> io::Error { + if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) { + no_such_process() + } else { + error + } +} + +/// Deliver `signal` (POSIX numbering) to the arbitrary process `pid`, +/// emulating `kill(2)` for `pid > 0`. `SeDebugPrivilege` is never enabled. +pub fn send_signal_to_pid(pid: u32, signal: usize) -> io::Result<()> { + // pid 0 is the System Idle Process; its ERROR_INVALID_PARAMETER would + // masquerade as ESRCH. + if pid == 0 { + return Err(io::ErrorKind::InvalidInput.into()); + } + match disposition(signal)? { + Disposition::Probe | Disposition::Ignore => probe_pid(pid), + Disposition::Interrupt | Disposition::Terminate => terminate_pid(pid, signal), + } +} + +/// Ok while `pid` runs, "No such process" once it has exited, even if open +/// handles still pin its pid. +fn probe_pid(pid: u32) -> io::Result<()> { + let handle = sys::open_process(pid, PROBE_ACCESS).map_err(map_open_error)?; + if has_exited(handle.as_handle())? { + return Err(no_such_process()); + } + Ok(()) +} + +fn terminate_pid(pid: u32, signal: usize) -> io::Result<()> { + let handle = sys::open_process(pid, TERMINATE_ACCESS).map_err(map_open_error)?; + match terminate_with_signal(handle.as_handle(), signal) { + // An already-exited target also reports ERROR_ACCESS_DENIED; it + // counts as delivered, like unix kill on an unreaped process. + Err(e) + if e.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32) + && has_exited(handle.as_handle()).unwrap_or(false) => + { + Ok(()) + } + result => result, + } +} + /// Deliver `signal` (POSIX numbering) to the console process group led by /// `pid` (the process must have been created with `CREATE_NEW_PROCESS_GROUP`, /// e.g. via [`configure_process_group`]). @@ -550,6 +628,14 @@ mod tests { ) } + fn spawn_child() -> Child { + Command::new("ping") + .args(["-n", "10", "127.0.0.1"]) + .stdout(Stdio::null()) + .spawn() + .unwrap() + } + /// `LAST_CTRL_SIGNAL` and `WAKE_EVENT` are process-global, so every phase /// runs inside this single test to keep them free of cross-test races /// (cargo runs tests on parallel threads). @@ -583,11 +669,7 @@ mod tests { // Phase 5: `wait_or_timeout` consumes a latched console event: it // returns `Interrupted` promptly with the child still running, and // leaves latch and event clear so they cannot satisfy a later wait. - let mut child = Command::new("ping") - .args(["-n", "10", "127.0.0.1"]) - .stdout(Stdio::null()) - .spawn() - .unwrap(); + let mut child = spawn_child(); // SAFETY: as above. assert_eq!(unsafe { console_ctrl_handler(CTRL_C_EVENT) }, 1); let started = Instant::now(); @@ -601,4 +683,44 @@ mod tests { child.kill().unwrap(); child.wait().unwrap(); } + + // The send_signal_to_pid tests touch no process-global state (unlike the + // ctrl-forwarding lifecycle above), so they can run in parallel. + + #[test] + fn send_signal_to_pid_terminates_with_128_plus_signal() { + let mut child = spawn_child(); + send_signal_to_pid(child.id(), 15).unwrap(); + assert_eq!(child.wait().unwrap().code(), Some(143)); + } + + #[test] + fn send_signal_to_pid_probe_ignore_and_exited_semantics() { + let mut child = spawn_child(); + send_signal_to_pid(child.id(), 0).unwrap(); + send_signal_to_pid(child.id(), 17).unwrap(); + assert!(child.try_wait().unwrap().is_none(), "CHLD must be a no-op"); + + child.kill().unwrap(); + child.wait().unwrap(); + // `child` still holds the process handle, pinning the pid: the checks + // below cannot race a pid reuse. + let err = send_signal_to_pid(child.id(), 0).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + assert_eq!(err.to_string(), "No such process"); + send_signal_to_pid(child.id(), 9).unwrap(); + } + + #[test] + fn send_signal_to_pid_rejects_invalid_input_before_any_open() { + let own_pid = std::process::id(); + assert_eq!( + send_signal_to_pid(0, 9).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + send_signal_to_pid(own_pid, 64).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + } } diff --git a/tests/by-util/test_kill.rs b/tests/by-util/test_kill.rs index 009285a22b0..00f57ea7926 100644 --- a/tests/by-util/test_kill.rs +++ b/tests/by-util/test_kill.rs @@ -2,39 +2,56 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore IAMNOTASIGNAL RTMAX RTMIN SIGIO SIGRTMAX GHSA +// spell-checker:ignore IAMNOTASIGNAL RTMAX RTMIN SIGIO SIGRTMAX GHSA CHLD SIGSTOP taskkill unreaped use regex::Regex; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; -use std::process::{Child, Command}; +use std::process::{Child, Command, ExitStatus}; #[cfg(any(target_os = "linux", target_os = "android"))] use uucore::signals::realtime_signal_bounds; use uutests::new_ucmd; +use uutests::util::get_tests_binary; -// A child process the tests will try to kill. +// A child process the tests will try to kill: ` sleep 30` via the +// multicall test binary, which exists on every test platform (unlike `sleep` +// from PATH on Windows). The natural death after 30s avoids hanging failing +// tests. struct Target { child: Child, killed: bool, } impl Target { - // Creates a target that will naturally die after some time if not killed - // fast enough. - // This timeout avoids hanging failing tests. fn new() -> Self { Self { - child: Command::new("sleep") - .arg("30") + child: Command::new(get_tests_binary()) + .args(["sleep", "30"]) .spawn() .expect("cannot spawn target"), killed: false, } } - // Waits for the target to complete and returns the signal it received if any. - fn wait_for_signal(&mut self) -> Option { - let sig = self.child.wait().expect("cannot wait on target").signal(); + /// Wait for the target to exit, so `Drop` no longer has to kill it. + fn reap(&mut self) -> ExitStatus { + let status = self.child.wait().expect("cannot wait on target"); self.killed = true; - sig + status + } + + /// Reap the target and assert it was killed by `signal` (`128 + n` exit + /// code on windows). + fn assert_signaled(&mut self, signal: i32) { + let status = self.reap(); + #[cfg(unix)] + assert_eq!(status.signal(), Some(signal)); + #[cfg(windows)] + assert_eq!(status.code(), Some(128 + signal)); + } + + #[cfg(windows)] + fn assert_alive(&mut self) { + assert!(self.child.try_wait().expect("cannot poll target").is_none()); } fn pid(&self) -> u32 { @@ -266,10 +283,15 @@ fn test_kill_out_of_range_signal_is_rejected_not_sent() { .arg(format!("{}", target.pid())) .fails_with_code(1) .stderr_contains("invalid signal"); - // The target must have survived: kill it for real and confirm it was - // the SIGKILL we just sent, not an earlier stray SIGTERM. + // The target must have survived: kill it for real and confirm the + // exit came from std's kill (SIGKILL / code 1), not a stray uu-kill + // TERM (which would report 143 on windows). target.child.kill().expect("cannot kill surviving target"); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + let status = target.reap(); + #[cfg(unix)] + assert_eq!(status.signal(), Some(9)); + #[cfg(windows)] + assert_eq!(status.code(), Some(1)); } } @@ -277,7 +299,7 @@ fn test_kill_out_of_range_signal_is_rejected_not_sent() { fn test_kill_with_default_signal() { let mut target = Target::new(); new_ucmd!().arg(format!("{}", target.pid())).succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGTERM)); + target.assert_signaled(15); } #[test] @@ -287,7 +309,7 @@ fn test_kill_with_signal_number_old_form() { .arg("-9") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(9)); + target.assert_signaled(9); } #[test] @@ -298,7 +320,7 @@ fn test_kill_with_signal_name_old_form() { .arg(arg) .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } } @@ -319,7 +341,7 @@ fn test_kill_with_signal_prefixed_name_old_form() { .arg("-SIGKILL") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -330,7 +352,7 @@ fn test_kill_with_signal_number_new_form() { .arg("9") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(9)); + target.assert_signaled(9); } #[test] @@ -341,7 +363,7 @@ fn test_kill_with_signal_name_new_form() { .arg("KILL") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -352,7 +374,7 @@ fn test_kill_with_signal_name_new_form_ignore_case() { .arg("KiLl") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -363,7 +385,7 @@ fn test_kill_with_signal_prefixed_name_new_form() { .arg("SIGKILL") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -374,7 +396,7 @@ fn test_kill_with_signal_prefixed_name_new_form_ignore_case() { .arg("SiGKiLl") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGKILL)); + target.assert_signaled(9); } #[test] @@ -413,7 +435,7 @@ fn test_kill_with_signal_number_hidden_compatibility_option() { .arg("9") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(9)); + target.assert_signaled(9); } #[test] @@ -585,12 +607,105 @@ fn test_kill_signal_zero_nonexistent() { new_ucmd!().arg("-0").arg("999999999").fails(); } +#[cfg(unix)] #[test] fn test_kill_signal_zero_current_process_group() { // kill -0 0 should succeed (checks current process group) new_ucmd!().arg("-0").arg("0").succeeds(); } +#[cfg(windows)] +#[test] +fn test_kill_windows_help_mentions_taskkill() { + new_ucmd!() + .arg("--help") + .succeeds() + .stdout_contains("taskkill"); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_int_and_hup_terminate_directly() { + for (name, sig) in [("INT", 2), ("HUP", 1)] { + let mut target = Target::new(); + new_ucmd!() + .args(&["-s", name, &target.pid().to_string()]) + .succeeds(); + target.assert_signaled(sig); + } +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_ignored_signals_are_noops() { + let mut target = Target::new(); + for sig in ["CHLD", "CONT"] { + new_ucmd!() + .args(&["-s", sig, &target.pid().to_string()]) + .succeeds(); + } + target.assert_alive(); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_stop_is_rejected() { + let mut target = Target::new(); + new_ucmd!() + .args(&["-s", "STOP", &target.pid().to_string()]) + .fails_with_code(1) + .stderr_contains("SIGSTOP is not supported"); + target.assert_alive(); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_process_groups_unsupported() { + new_ucmd!() + .args(&["-0", "0"]) + .fails_with_code(1) + .stderr_contains("process groups are not supported"); + new_ucmd!() + .args(&["-9", "-1"]) + .fails_with_code(1) + .stderr_contains("process groups are not supported"); + let target = Target::new(); + new_ucmd!() + .arg("--") + .arg(format!("-{}", target.pid())) + .fails_with_code(1) + .stderr_contains("process groups are not supported"); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_nonexistent_pid_no_such_process() { + new_ucmd!() + .arg("999999999") + .fails_with_code(1) + .stderr_contains("No such process"); + new_ucmd!() + .args(&["-0", "999999999"]) + .fails_with_code(1) + .stderr_contains("No such process"); +} + +#[cfg(windows)] +#[test] +fn test_kill_windows_exited_target_with_held_handle() { + // The Child handle pins the exited process object: terminating still + // succeeds (unix kill-on-unreaped parity) while -0 reports it gone. + let mut target = Target::new(); + target.child.kill().expect("cannot kill target"); + target.reap(); + let pid = target.pid().to_string(); + new_ucmd!().arg(&pid).succeeds(); + new_ucmd!() + .args(&["-0", &pid]) + .fails_with_code(1) + .stderr_contains("No such process"); +} + #[cfg(any(target_os = "linux", target_os = "android"))] #[test] fn test_kill_realtime_signal() { @@ -601,7 +716,7 @@ fn test_kill_realtime_signal() { .arg("RTMIN") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(libc::SIGRTMIN())); + target.assert_signaled(libc::SIGRTMIN()); } #[cfg(any(target_os = "linux", target_os = "android"))] @@ -616,7 +731,7 @@ fn test_kill_with_rtmax_offset() { .arg("SIGRTMAX-7") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(sig)); + target.assert_signaled(sig); } #[cfg(any(target_os = "linux", target_os = "android"))] @@ -631,5 +746,5 @@ fn test_kill_with_rtmin_offset() { .arg("SIGRTMIN+7") .arg(format!("{}", target.pid())) .succeeds(); - assert_eq!(target.wait_for_signal(), Some(sig)); + target.assert_signaled(sig); } From 358e372bdbc42bf357090d7906a0c8ff6bc60f13 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Mon, 27 Jul 2026 16:03:56 +0200 Subject: [PATCH 2/6] kill: localize Windows error messages --- src/uu/kill/locales/en-US.ftl | 2 ++ src/uu/kill/locales/fr-FR.ftl | 2 ++ src/uu/kill/src/platform/windows.rs | 9 ++++++--- src/uucore/locales/en-US.ftl | 1 + src/uucore/locales/fr-FR.ftl | 1 + src/uucore/src/lib/features/process/windows.rs | 6 ++++-- 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/uu/kill/locales/en-US.ftl b/src/uu/kill/locales/en-US.ftl index e62be4976b4..7eebf07de1f 100644 --- a/src/uu/kill/locales/en-US.ftl +++ b/src/uu/kill/locales/en-US.ftl @@ -19,3 +19,5 @@ kill-error-invalid-signal = { $signal }: invalid signal kill-error-parse-argument = failed to parse argument { $argument }: { $error } kill-error-sending-signal = sending signal to { $pid } failed kill-error-write = write error: { $error } +kill-error-stop-unsupported = SIGSTOP is not supported on Windows +kill-error-process-groups-unsupported = process groups are not supported on Windows diff --git a/src/uu/kill/locales/fr-FR.ftl b/src/uu/kill/locales/fr-FR.ftl index 3d2582cd008..e04903e2bc1 100644 --- a/src/uu/kill/locales/fr-FR.ftl +++ b/src/uu/kill/locales/fr-FR.ftl @@ -20,3 +20,5 @@ kill-error-invalid-signal = { $signal } : signal invalide kill-error-parse-argument = échec de l'analyse de l'argument { $argument } : { $error } kill-error-sending-signal = échec de l'envoi du signal au processus { $pid } kill-error-write = erreur d'écriture : { $error } +kill-error-stop-unsupported = SIGSTOP n'est pas pris en charge sur Windows +kill-error-process-groups-unsupported = les groupes de processus ne sont pas pris en charge sur Windows diff --git a/src/uu/kill/src/platform/windows.rs b/src/uu/kill/src/platform/windows.rs index a2723d7eff1..9ec0baf2a55 100644 --- a/src/uu/kill/src/platform/windows.rs +++ b/src/uu/kill/src/platform/windows.rs @@ -10,19 +10,22 @@ use std::io; use uucore::process::send_signal_to_pid; +use uucore::translate; const SIGNAL_STOP: usize = 19; -fn unsupported(message: &'static str) -> io::Error { +fn unsupported(message: String) -> io::Error { io::Error::new(io::ErrorKind::Unsupported, message) } pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { if sig == SIGNAL_STOP { - return Err(unsupported("SIGSTOP is not supported on Windows")); + return Err(unsupported(translate!("kill-error-stop-unsupported"))); } match u32::try_from(pid) { Ok(pid) if pid != 0 => send_signal_to_pid(pid, sig), - _ => Err(unsupported("process groups are not supported on Windows")), + _ => Err(unsupported(translate!( + "kill-error-process-groups-unsupported" + ))), } } diff --git a/src/uucore/locales/en-US.ftl b/src/uucore/locales/en-US.ftl index f408b4ee69e..964c8f2ba6b 100644 --- a/src/uucore/locales/en-US.ftl +++ b/src/uucore/locales/en-US.ftl @@ -29,6 +29,7 @@ help-flag-version = Print version information error-io = I/O error error-permission-denied = Permission denied error-file-not-found = No such file or directory +error-no-such-process = No such process error-invalid-argument = Invalid argument error-is-a-directory = { $file }: Is a directory diff --git a/src/uucore/locales/fr-FR.ftl b/src/uucore/locales/fr-FR.ftl index 507a536cc6e..f471689009d 100644 --- a/src/uucore/locales/fr-FR.ftl +++ b/src/uucore/locales/fr-FR.ftl @@ -29,6 +29,7 @@ help-flag-version = Afficher les informations de version error-io = Erreur E/S error-permission-denied = Permission refusée error-file-not-found = Aucun fichier ou répertoire de ce type +error-no-such-process = Aucun processus de ce type error-invalid-argument = Argument invalide error-is-a-directory = { $file }: Est un répertoire diff --git a/src/uucore/src/lib/features/process/windows.rs b/src/uucore/src/lib/features/process/windows.rs index c05d7349f7d..444ef904530 100644 --- a/src/uucore/src/lib/features/process/windows.rs +++ b/src/uucore/src/lib/features/process/windows.rs @@ -35,6 +35,7 @@ use windows_sys::Win32::System::Threading::{ use windows_sys::core::BOOL; use super::{ChildExt, TimeoutRet}; +use crate::translate; /// Safe wrappers around the raw Win32 calls used for process control: each /// validates results into [`io::Error`] and takes @@ -318,7 +319,7 @@ const PROBE_ACCESS: u32 = PROCESS_SYNCHRONIZE; const TERMINATE_ACCESS: u32 = PROCESS_TERMINATE | PROCESS_SYNCHRONIZE; fn no_such_process() -> io::Error { - io::Error::new(io::ErrorKind::NotFound, "No such process") + io::Error::new(io::ErrorKind::NotFound, translate!("error-no-such-process")) } /// A process handle becomes signaled when the process exits. @@ -705,9 +706,10 @@ mod tests { child.wait().unwrap(); // `child` still holds the process handle, pinning the pid: the checks // below cannot race a pid reuse. + // The message text is asserted end-to-end in tests/by-util/test_kill.rs; + // unit tests run without localization initialized. let err = send_signal_to_pid(child.id(), 0).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound); - assert_eq!(err.to_string(), "No such process"); send_signal_to_pid(child.id(), 9).unwrap(); } From d4fa484cc8cbe87a1773d99f384f7cb047142599 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Tue, 28 Jul 2026 13:55:20 +0200 Subject: [PATCH 3/6] kill: use non-specific error message for signals --- src/uu/kill/locales/en-US.ftl | 2 +- src/uu/kill/locales/fr-FR.ftl | 2 +- src/uu/kill/src/platform/windows.rs | 2 +- tests/by-util/test_kill.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/uu/kill/locales/en-US.ftl b/src/uu/kill/locales/en-US.ftl index 7eebf07de1f..3b6023feecf 100644 --- a/src/uu/kill/locales/en-US.ftl +++ b/src/uu/kill/locales/en-US.ftl @@ -19,5 +19,5 @@ kill-error-invalid-signal = { $signal }: invalid signal kill-error-parse-argument = failed to parse argument { $argument }: { $error } kill-error-sending-signal = sending signal to { $pid } failed kill-error-write = write error: { $error } -kill-error-stop-unsupported = SIGSTOP is not supported on Windows +kill-error-unsupported-signal = unsupported signal on Windows kill-error-process-groups-unsupported = process groups are not supported on Windows diff --git a/src/uu/kill/locales/fr-FR.ftl b/src/uu/kill/locales/fr-FR.ftl index e04903e2bc1..049d9050d7e 100644 --- a/src/uu/kill/locales/fr-FR.ftl +++ b/src/uu/kill/locales/fr-FR.ftl @@ -20,5 +20,5 @@ kill-error-invalid-signal = { $signal } : signal invalide kill-error-parse-argument = échec de l'analyse de l'argument { $argument } : { $error } kill-error-sending-signal = échec de l'envoi du signal au processus { $pid } kill-error-write = erreur d'écriture : { $error } -kill-error-stop-unsupported = SIGSTOP n'est pas pris en charge sur Windows +kill-error-unsupported-signal = signal non pris en charge sur Windows kill-error-process-groups-unsupported = les groupes de processus ne sont pas pris en charge sur Windows diff --git a/src/uu/kill/src/platform/windows.rs b/src/uu/kill/src/platform/windows.rs index 9ec0baf2a55..39b8ac81d26 100644 --- a/src/uu/kill/src/platform/windows.rs +++ b/src/uu/kill/src/platform/windows.rs @@ -20,7 +20,7 @@ fn unsupported(message: String) -> io::Error { pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { if sig == SIGNAL_STOP { - return Err(unsupported(translate!("kill-error-stop-unsupported"))); + return Err(unsupported(translate!("kill-error-unsupported-signal"))); } match u32::try_from(pid) { Ok(pid) if pid != 0 => send_signal_to_pid(pid, sig), diff --git a/tests/by-util/test_kill.rs b/tests/by-util/test_kill.rs index 00f57ea7926..c322eb97c10 100644 --- a/tests/by-util/test_kill.rs +++ b/tests/by-util/test_kill.rs @@ -654,7 +654,7 @@ fn test_kill_windows_stop_is_rejected() { new_ucmd!() .args(&["-s", "STOP", &target.pid().to_string()]) .fails_with_code(1) - .stderr_contains("SIGSTOP is not supported"); + .stderr_contains("unsupported signal"); target.assert_alive(); } From 9dcb1d04505df5930b4a2b52299461386fc4e499 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Tue, 28 Jul 2026 20:54:58 +0200 Subject: [PATCH 4/6] kill: fold map_open_error into open_process, remove unneeded pid==0 check in send_signal_to_pid --- .../src/lib/features/process/windows.rs | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/uucore/src/lib/features/process/windows.rs b/src/uucore/src/lib/features/process/windows.rs index 444ef904530..3a09b9d3f79 100644 --- a/src/uucore/src/lib/features/process/windows.rs +++ b/src/uucore/src/lib/features/process/windows.rs @@ -27,7 +27,7 @@ use std::sync::OnceLock; use std::sync::atomic::{AtomicI32, Ordering}; use std::time::Duration; -use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER}; +use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT}; use windows_sys::Win32::System::Threading::{ CREATE_NEW_PROCESS_GROUP, INFINITE, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, @@ -45,7 +45,7 @@ pub mod sys { use std::os::windows::io::{AsRawHandle, BorrowedHandle, HandleOrNull, OwnedHandle}; use windows_sys::Win32::Foundation::{ - FALSE, HANDLE, TRUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, + ERROR_INVALID_PARAMETER, FALSE, HANDLE, TRUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows_sys::Win32::System::Console::{ GenerateConsoleCtrlEvent, PHANDLER_ROUTINE, SetConsoleCtrlHandler, @@ -123,10 +123,20 @@ pub mod sys { } /// Open `pid` with exactly `desired_access`; the handle is non-inheritable. + /// + /// Dead, never-allocated and idle (0) pids report `ERROR_INVALID_PARAMETER`, + /// reported here as the POSIX ESRCH analog. `ERROR_ACCESS_DENIED` stays raw: + /// its kind already renders "Permission denied", like unix EPERM. pub fn open_process(pid: u32, desired_access: u32) -> io::Result { // SAFETY: OpenProcess returns null on failure, otherwise a fresh // handle owned by us — the `cvt_created_handle` contract. - unsafe { cvt_created_handle(OpenProcess(desired_access, FALSE, pid)) } + unsafe { cvt_created_handle(OpenProcess(desired_access, FALSE, pid)) }.map_err(|error| { + if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) { + super::no_such_process() + } else { + error + } + }) } /// Create an unnamed manual-reset event, initially unsignaled. @@ -330,25 +340,9 @@ fn has_exited(handle: BorrowedHandle) -> io::Result { )) } -// OpenProcess reports dead and never-allocated pids as ERROR_INVALID_PARAMETER; -// the custom message matches unix ESRCH stderr. ERROR_ACCESS_DENIED stays raw -// (already renders "Permission denied", like unix EPERM). -fn map_open_error(error: io::Error) -> io::Error { - if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) { - no_such_process() - } else { - error - } -} - /// Deliver `signal` (POSIX numbering) to the arbitrary process `pid`, /// emulating `kill(2)` for `pid > 0`. `SeDebugPrivilege` is never enabled. pub fn send_signal_to_pid(pid: u32, signal: usize) -> io::Result<()> { - // pid 0 is the System Idle Process; its ERROR_INVALID_PARAMETER would - // masquerade as ESRCH. - if pid == 0 { - return Err(io::ErrorKind::InvalidInput.into()); - } match disposition(signal)? { Disposition::Probe | Disposition::Ignore => probe_pid(pid), Disposition::Interrupt | Disposition::Terminate => terminate_pid(pid, signal), @@ -358,7 +352,7 @@ pub fn send_signal_to_pid(pid: u32, signal: usize) -> io::Result<()> { /// Ok while `pid` runs, "No such process" once it has exited, even if open /// handles still pin its pid. fn probe_pid(pid: u32) -> io::Result<()> { - let handle = sys::open_process(pid, PROBE_ACCESS).map_err(map_open_error)?; + let handle = sys::open_process(pid, PROBE_ACCESS)?; if has_exited(handle.as_handle())? { return Err(no_such_process()); } @@ -366,7 +360,7 @@ fn probe_pid(pid: u32) -> io::Result<()> { } fn terminate_pid(pid: u32, signal: usize) -> io::Result<()> { - let handle = sys::open_process(pid, TERMINATE_ACCESS).map_err(map_open_error)?; + let handle = sys::open_process(pid, TERMINATE_ACCESS)?; match terminate_with_signal(handle.as_handle(), signal) { // An already-exited target also reports ERROR_ACCESS_DENIED; it // counts as delivered, like unix kill on an unreaped process. @@ -714,15 +708,22 @@ mod tests { } #[test] - fn send_signal_to_pid_rejects_invalid_input_before_any_open() { - let own_pid = std::process::id(); + fn send_signal_to_pid_rejects_invalid_signal_before_any_open() { assert_eq!( - send_signal_to_pid(0, 9).unwrap_err().kind(), + send_signal_to_pid(std::process::id(), 64) + .unwrap_err() + .kind(), io::ErrorKind::InvalidInput ); + } + + #[test] + fn send_signal_to_pid_reports_idle_pid_as_missing() { + // OpenProcess rejects the System Idle Process like any pid that names + // no process. assert_eq!( - send_signal_to_pid(own_pid, 64).unwrap_err().kind(), - io::ErrorKind::InvalidInput + send_signal_to_pid(0, 9).unwrap_err().kind(), + io::ErrorKind::NotFound ); } } From 5e869268171a276742328d8bbd461d7943f3ec83 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Wed, 29 Jul 2026 14:41:49 +0200 Subject: [PATCH 5/6] kill: try and elevate privileges if possible --- src/uu/kill/locales/en-US.ftl | 7 +- src/uu/kill/locales/fr-FR.ftl | 7 +- src/uu/kill/src/platform/windows.rs | 7 +- .../src/lib/features/process/windows.rs | 78 +++++++++++++++++-- tests/by-util/test_kill.rs | 4 +- 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/src/uu/kill/locales/en-US.ftl b/src/uu/kill/locales/en-US.ftl index 3b6023feecf..83c5ec0518c 100644 --- a/src/uu/kill/locales/en-US.ftl +++ b/src/uu/kill/locales/en-US.ftl @@ -3,9 +3,10 @@ kill-usage = kill [OPTIONS]... PID... kill-after-help-windows = Windows notes: Signalled processes are force-terminated (Windows has no signal delivery); their exit status is 128 plus the signal number. Process groups (PID <= 0) - and STOP are not supported. Permissions come from your current token: kill - never enables SeDebugPrivilege, so an elevated kill may report 'Permission - denied' where 'taskkill /F' succeeds. + and STOP are not supported. Permissions come from your current token, with + SeDebugPrivilege enabled when it is held, so run elevated to reach processes + a standard token cannot signal. Protected (anti-malware) processes cannot be + terminated at all. # Help messages kill-help-list = Lists signals diff --git a/src/uu/kill/locales/fr-FR.ftl b/src/uu/kill/locales/fr-FR.ftl index 049d9050d7e..8c97e104440 100644 --- a/src/uu/kill/locales/fr-FR.ftl +++ b/src/uu/kill/locales/fr-FR.ftl @@ -4,9 +4,10 @@ kill-after-help-windows = Notes pour Windows : Les processus signalés sont terminés de force (Windows ne délivre pas de signaux) ; leur code de sortie est 128 plus le numéro du signal. Les groupes de processus (PID <= 0) et STOP ne sont pas pris en charge. Les permissions - proviennent de votre jeton actuel : kill n'active jamais SeDebugPrivilege, - donc un kill élevé peut signaler « Permission denied » là où « taskkill /F » - réussit. + proviennent de votre jeton actuel, avec SeDebugPrivilege activé lorsqu'il est + détenu ; exécutez kill en tant qu'administrateur pour atteindre les processus + qu'un jeton standard ne peut pas signaler. Les processus protégés + (anti-programmes malveillants) ne peuvent jamais être terminés. # Messages d'aide kill-help-list = Liste les signaux diff --git a/src/uu/kill/src/platform/windows.rs b/src/uu/kill/src/platform/windows.rs index 39b8ac81d26..594b4c32405 100644 --- a/src/uu/kill/src/platform/windows.rs +++ b/src/uu/kill/src/platform/windows.rs @@ -9,7 +9,7 @@ use std::io; -use uucore::process::send_signal_to_pid; +use uucore::process::{enable_debug_privilege, send_signal_to_pid}; use uucore::translate; const SIGNAL_STOP: usize = 19; @@ -23,7 +23,10 @@ pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { return Err(unsupported(translate!("kill-error-unsupported-signal"))); } match u32::try_from(pid) { - Ok(pid) if pid != 0 => send_signal_to_pid(pid, sig), + Ok(pid) if pid != 0 => { + enable_debug_privilege(); + send_signal_to_pid(pid, sig) + } _ => Err(unsupported(translate!( "kill-error-process-groups-unsupported" ))), diff --git a/src/uucore/src/lib/features/process/windows.rs b/src/uucore/src/lib/features/process/windows.rs index 3a09b9d3f79..d09cbfa3ced 100644 --- a/src/uucore/src/lib/features/process/windows.rs +++ b/src/uucore/src/lib/features/process/windows.rs @@ -5,7 +5,7 @@ // spell-checker:ignore (win-api) WAITABLE Waitable PHANDLER unsignaled // spell-checker:ignore (signals) CHLD TSTP TTIN TTOU WINCH ESRCH -// spell-checker:ignore catchable targetable wakeup unreaped pids +// spell-checker:ignore catchable targetable wakeup unreaped pids LUID luid //! Windows emulation of POSIX signal delivery, for child processes and //! arbitrary pids ([`send_signal_to_pid`]). @@ -42,10 +42,17 @@ use crate::translate; /// [`BorrowedHandle`]/[`OwnedHandle`] so callers never touch raw `HANDLE`s. pub mod sys { use std::io; - use std::os::windows::io::{AsRawHandle, BorrowedHandle, HandleOrNull, OwnedHandle}; + use std::os::windows::io::{ + AsRawHandle, BorrowedHandle, FromRawHandle, HandleOrNull, OwnedHandle, + }; use windows_sys::Win32::Foundation::{ - ERROR_INVALID_PARAMETER, FALSE, HANDLE, TRUE, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, + ERROR_INVALID_PARAMETER, FALSE, HANDLE, LUID, TRUE, WAIT_FAILED, WAIT_OBJECT_0, + WAIT_TIMEOUT, + }; + use windows_sys::Win32::Security::{ + AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, SE_DEBUG_NAME, + SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, }; use windows_sys::Win32::System::Console::{ GenerateConsoleCtrlEvent, PHANDLER_ROUTINE, SetConsoleCtrlHandler, @@ -54,9 +61,9 @@ pub mod sys { AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject, }; use windows_sys::Win32::System::Threading::{ - CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, CreateEventW, CreateWaitableTimerExW, OpenProcess, - ResetEvent, SetEvent, SetWaitableTimer, TIMER_ALL_ACCESS, TerminateProcess, - WaitForMultipleObjects, WaitForSingleObject, + CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, CreateEventW, CreateWaitableTimerExW, + GetCurrentProcess, OpenProcess, OpenProcessToken, ResetEvent, SetEvent, SetWaitableTimer, + TIMER_ALL_ACCESS, TerminateProcess, WaitForMultipleObjects, WaitForSingleObject, }; use windows_sys::core::BOOL; @@ -139,6 +146,45 @@ pub mod sys { }) } + /// Request `SeDebugPrivilege` in this process's token. + /// + /// `Ok` does not mean the privilege is now enabled: `AdjustTokenPrivileges` + /// succeeds for a token that does not hold it too, reporting that only + /// through `GetLastError`. + pub fn enable_debug_privilege() -> io::Result<()> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: the pseudo-handle is always valid; `token` is an out-param. + cvt(unsafe { + OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, &raw mut token) + })?; + // SAFETY: OpenProcessToken succeeded, so `token` is a fresh owned handle. + let token = unsafe { OwnedHandle::from_raw_handle(token) }; + + let mut luid = LUID::default(); + // SAFETY: a null system name means the local system; `luid` is an out-param. + cvt(unsafe { LookupPrivilegeValueW(std::ptr::null(), SE_DEBUG_NAME, &raw mut luid) })?; + + let privileges = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + // SAFETY: `privileges` outlives the call; a null previous-state pointer + // is documented as valid. + cvt(unsafe { + AdjustTokenPrivileges( + token.as_raw_handle(), + FALSE, + &raw const privileges, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }) + } + /// Create an unnamed manual-reset event, initially unsignaled. pub fn create_manual_reset_event() -> io::Result { // SAFETY: null attributes and name are documented as valid; the @@ -340,8 +386,20 @@ fn has_exited(handle: BorrowedHandle) -> io::Result { )) } +/// Request `SeDebugPrivilege` once per process; with it, opening a process +/// bypasses the target's security descriptor. +/// +/// Silent and best-effort: only an elevated administrator's token holds the +/// privilege, and tokens without it are left untouched. +pub fn enable_debug_privilege() { + static REQUESTED: OnceLock<()> = OnceLock::new(); + REQUESTED.get_or_init(|| { + let _ = sys::enable_debug_privilege(); + }); +} + /// Deliver `signal` (POSIX numbering) to the arbitrary process `pid`, -/// emulating `kill(2)` for `pid > 0`. `SeDebugPrivilege` is never enabled. +/// emulating `kill(2)` for `pid > 0`. pub fn send_signal_to_pid(pid: u32, signal: usize) -> io::Result<()> { match disposition(signal)? { Disposition::Probe | Disposition::Ignore => probe_pid(pid), @@ -717,6 +775,12 @@ mod tests { ); } + #[test] + fn enable_debug_privilege_is_silent_whether_or_not_the_token_holds_it() { + enable_debug_privilege(); + enable_debug_privilege(); + } + #[test] fn send_signal_to_pid_reports_idle_pid_as_missing() { // OpenProcess rejects the System Idle Process like any pid that names diff --git a/tests/by-util/test_kill.rs b/tests/by-util/test_kill.rs index c322eb97c10..c304e654422 100644 --- a/tests/by-util/test_kill.rs +++ b/tests/by-util/test_kill.rs @@ -616,11 +616,11 @@ fn test_kill_signal_zero_current_process_group() { #[cfg(windows)] #[test] -fn test_kill_windows_help_mentions_taskkill() { +fn test_kill_windows_help_has_platform_notes() { new_ucmd!() .arg("--help") .succeeds() - .stdout_contains("taskkill"); + .stdout_contains("Windows notes"); } #[cfg(windows)] From c2377ca7ef7ac700f9e722e77e0d464c714bb136 Mon Sep 17 00:00:00 2001 From: Nikola Lukovic Date: Wed, 29 Jul 2026 18:58:00 +0200 Subject: [PATCH 6/6] kill: implement a version of group process kill for windows --- src/uu/kill/locales/en-US.ftl | 23 +- src/uu/kill/locales/fr-FR.ftl | 27 +- src/uu/kill/src/platform/windows.rs | 49 +++- .../src/lib/features/process/windows.rs | 273 +++++++++++++++++- tests/by-util/test_kill.rs | 132 ++++++++- 5 files changed, 462 insertions(+), 42 deletions(-) diff --git a/src/uu/kill/locales/en-US.ftl b/src/uu/kill/locales/en-US.ftl index 83c5ec0518c..ff9491b0827 100644 --- a/src/uu/kill/locales/en-US.ftl +++ b/src/uu/kill/locales/en-US.ftl @@ -2,11 +2,22 @@ kill-about = Send signal to processes or list information about signals. kill-usage = kill [OPTIONS]... PID... kill-after-help-windows = Windows notes: Signalled processes are force-terminated (Windows has no signal delivery); - their exit status is 128 plus the signal number. Process groups (PID <= 0) - and STOP are not supported. Permissions come from your current token, with - SeDebugPrivilege enabled when it is held, so run elevated to reach processes - a standard token cannot signal. Protected (anti-malware) processes cannot be - terminated at all. + their exit status is 128 plus the signal number. Negative PIDs (another + process's group) and STOP are not supported. Permissions come from your + current token, with SeDebugPrivilege enabled when it is held, so run + elevated to reach processes a standard token cannot signal. Protected + (anti-malware) processes cannot be terminated at all. + + PID 0 targets the Job object kill runs in, the closest Windows analog of a + process group. Every process in that job and in its child jobs is signalled, + kill itself last, so kill dies with the group. Outside a job, PID 0 signals + only kill itself. + + Beware: a Job object is usually not yours. Terminals, IDEs, Docker, CI + agents and Windows' own Program Compatibility Assistant all run what they + launch inside a job, and a job captures every descendant from creation + onward. Under a CI agent, kill 0 signals the agent and every sibling step. + The blast radius can be far wider than a POSIX process group. # Help messages kill-help-list = Lists signals @@ -21,4 +32,4 @@ kill-error-parse-argument = failed to parse argument { $argument }: { $error } kill-error-sending-signal = sending signal to { $pid } failed kill-error-write = write error: { $error } kill-error-unsupported-signal = unsupported signal on Windows -kill-error-process-groups-unsupported = process groups are not supported on Windows +kill-error-negative-pid-unsupported = a negative PID (another process's group) is not supported on Windows diff --git a/src/uu/kill/locales/fr-FR.ftl b/src/uu/kill/locales/fr-FR.ftl index 8c97e104440..729899b6806 100644 --- a/src/uu/kill/locales/fr-FR.ftl +++ b/src/uu/kill/locales/fr-FR.ftl @@ -2,12 +2,25 @@ kill-about = Envoyer un signal aux processus ou lister les informations sur les kill-usage = kill [OPTIONS]... PID... kill-after-help-windows = Notes pour Windows : Les processus signalés sont terminés de force (Windows ne délivre pas de - signaux) ; leur code de sortie est 128 plus le numéro du signal. Les groupes - de processus (PID <= 0) et STOP ne sont pas pris en charge. Les permissions - proviennent de votre jeton actuel, avec SeDebugPrivilege activé lorsqu'il est - détenu ; exécutez kill en tant qu'administrateur pour atteindre les processus - qu'un jeton standard ne peut pas signaler. Les processus protégés - (anti-programmes malveillants) ne peuvent jamais être terminés. + signaux) ; leur code de sortie est 128 plus le numéro du signal. Les PID + négatifs (le groupe d'un autre processus) et STOP ne sont pas pris en charge. + Les permissions proviennent de votre jeton actuel, avec SeDebugPrivilege + activé lorsqu'il est détenu ; exécutez kill en tant qu'administrateur pour + atteindre les processus qu'un jeton standard ne peut pas signaler. Les + processus protégés (anti-programmes malveillants) ne peuvent jamais être + terminés. + + Le PID 0 cible l'objet Job dans lequel kill s'exécute, l'équivalent Windows + le plus proche d'un groupe de processus. Tous les processus de ce Job et de + ses Jobs enfants sont signalés, kill lui-même en dernier, de sorte que kill + meurt avec le groupe. Hors d'un Job, le PID 0 ne signale que kill lui-même. + + Attention : un objet Job ne vous appartient généralement pas. Les terminaux, + les IDE, Docker, les agents d'intégration continue et l'Assistant de + compatibilité des programmes de Windows exécutent tous dans un Job ce qu'ils + lancent, et un Job capture chaque descendant dès sa création. Sous un agent + d'intégration continue, kill 0 signale l'agent et toutes les étapes voisines. + La portée peut être bien plus large que celle d'un groupe de processus POSIX. # Messages d'aide kill-help-list = Liste les signaux @@ -22,4 +35,4 @@ kill-error-parse-argument = échec de l'analyse de l'argument { $argument } : { kill-error-sending-signal = échec de l'envoi du signal au processus { $pid } kill-error-write = erreur d'écriture : { $error } kill-error-unsupported-signal = signal non pris en charge sur Windows -kill-error-process-groups-unsupported = les groupes de processus ne sont pas pris en charge sur Windows +kill-error-negative-pid-unsupported = un PID négatif (le groupe d'un autre processus) n'est pas pris en charge sur Windows diff --git a/src/uu/kill/src/platform/windows.rs b/src/uu/kill/src/platform/windows.rs index 594b4c32405..3b96bd23d7a 100644 --- a/src/uu/kill/src/platform/windows.rs +++ b/src/uu/kill/src/platform/windows.rs @@ -3,13 +3,17 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore pids + //! Windows implementation of `kill`'s platform facade, built on the signal -//! emulation in [`uucore::process`]. STOP (no process-suspend API) and -//! process groups (`pid <= 0`) have no Windows emulation and are rejected. +//! emulation in [`uucore::process`]. PID 0 targets the Job object `kill` runs +//! in, the closest Windows analog of a process group. STOP (no process-suspend +//! API) and negative pids (no way to name another process's group) are +//! rejected. use std::io; -use uucore::process::{enable_debug_privilege, send_signal_to_pid}; +use uucore::process::{enable_debug_privilege, send_signal_to_own_group, send_signal_to_pid}; use uucore::translate; const SIGNAL_STOP: usize = 19; @@ -23,12 +27,41 @@ pub(crate) fn send_signal(pid: i32, sig: usize) -> io::Result<()> { return Err(unsupported(translate!("kill-error-unsupported-signal"))); } match u32::try_from(pid) { - Ok(pid) if pid != 0 => { + // Fails exactly for pid < 0. + Err(_) => Err(unsupported(translate!( + "kill-error-negative-pid-unsupported" + ))), + Ok(pid) => { + // Group members need the same rights as a single target. enable_debug_privilege(); - send_signal_to_pid(pid, sig) + if pid == 0 { + send_signal_to_own_group(sig) + } else { + send_signal_to_pid(pid, sig) + } } - _ => Err(unsupported(translate!( - "kill-error-process-groups-unsupported" - ))), + } +} + +#[cfg(test)] +mod tests { + use super::send_signal; + + #[test] + fn negative_pids_are_rejected_without_touching_any_process() { + for pid in [-1, -2, i32::MIN] { + assert_eq!( + send_signal(pid, 9).unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); + } + } + + #[test] + fn stop_is_rejected_for_every_pid_including_zero() { + assert_eq!( + send_signal(0, 19).unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); } } diff --git a/src/uucore/src/lib/features/process/windows.rs b/src/uucore/src/lib/features/process/windows.rs index d09cbfa3ced..793d715cd72 100644 --- a/src/uucore/src/lib/features/process/windows.rs +++ b/src/uucore/src/lib/features/process/windows.rs @@ -3,22 +3,24 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (win-api) WAITABLE Waitable PHANDLER unsignaled +// spell-checker:ignore (win-api) WAITABLE Waitable PHANDLER unsignaled JOBOBJECT // spell-checker:ignore (signals) CHLD TSTP TTIN TTOU WINCH ESRCH // spell-checker:ignore catchable targetable wakeup unreaped pids LUID luid -//! Windows emulation of POSIX signal delivery, for child processes and -//! arbitrary pids ([`send_signal_to_pid`]). +//! Windows emulation of POSIX signal delivery, for child processes, arbitrary +//! pids ([`send_signal_to_pid`]) and the caller's own process group +//! ([`send_signal_to_own_group`]). //! //! Windows has no signals, so this module emulates the POSIX default //! dispositions with native primitives: signal numbers follow the Linux //! layout (matching `uucore::signals::ALL_SIGNALS`), "terminate" signals //! force-exit with exit code `128 + n`, and `INT`/`QUIT` map to a //! `CTRL_BREAK_EVENT` on a console process group. [`Job`] gives process-tree -//! termination, and [`enable_ctrl_forwarding`]/[`take_last_ctrl_signal`] -//! surface console control events (Ctrl-C, Ctrl-Break, close) as POSIX signal -//! numbers for forwarding. All raw Win32 calls live in the safe [`sys`] -//! wrappers. +//! termination, [`send_signal_to_own_group`] uses the job the caller runs in +//! as the stand-in for a process group, and +//! [`enable_ctrl_forwarding`]/[`take_last_ctrl_signal`] surface console +//! control events (Ctrl-C, Ctrl-Break, close) as POSIX signal numbers for +//! forwarding. All raw Win32 calls live in the safe [`sys`] wrappers. use std::io; use std::os::windows::io::{AsHandle, BorrowedHandle, OwnedHandle}; @@ -42,13 +44,14 @@ use crate::translate; /// [`BorrowedHandle`]/[`OwnedHandle`] so callers never touch raw `HANDLE`s. pub mod sys { use std::io; + use std::mem::offset_of; use std::os::windows::io::{ AsRawHandle, BorrowedHandle, FromRawHandle, HandleOrNull, OwnedHandle, }; use windows_sys::Win32::Foundation::{ - ERROR_INVALID_PARAMETER, FALSE, HANDLE, LUID, TRUE, WAIT_FAILED, WAIT_OBJECT_0, - WAIT_TIMEOUT, + ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, FALSE, HANDLE, LUID, TRUE, WAIT_FAILED, + WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows_sys::Win32::Security::{ AdjustTokenPrivileges, LUID_AND_ATTRIBUTES, LookupPrivilegeValueW, SE_DEBUG_NAME, @@ -58,7 +61,9 @@ pub mod sys { GenerateConsoleCtrlEvent, PHANDLER_ROUTINE, SetConsoleCtrlHandler, }; use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject, + AssignProcessToJobObject, CreateJobObjectW, IsProcessInJob, + JOBOBJECT_BASIC_PROCESS_ID_LIST, JobObjectBasicProcessIdList, QueryInformationJobObject, + TerminateJobObject, }; use windows_sys::Win32::System::Threading::{ CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, CreateEventW, CreateWaitableTimerExW, @@ -76,6 +81,13 @@ pub mod sys { TimedOut, } + /// The outcome of listing the processes assigned to a job. + #[derive(Debug)] + pub enum JobProcessIds { + Complete(Vec), + Truncated, + } + /// Convert a Win32 `BOOL` result into an [`io::Result`]: zero (`FALSE`) /// becomes the error from `GetLastError`. fn cvt(result: BOOL) -> io::Result<()> { @@ -122,6 +134,97 @@ pub mod sys { cvt(unsafe { TerminateJobObject(job.as_raw_handle() as HANDLE, exit_code) }) } + /// One slot on 64-bit, two on 32-bit. + const PID_LIST_HEADER_SLOTS: usize = + offset_of!(JOBOBJECT_BASIC_PROCESS_ID_LIST, ProcessIdList) / size_of::(); + + // `ProcessIdList` is a variable-length trailing array, so its buffers are + // allocated as `usize` slices: aligned for the struct by construction, and + // readable with bounds-checked indexing rather than pointer arithmetic. + // A target where that stops holding is a compile error, not run-time UB. + const _: () = assert!( + align_of::() == align_of::() + && offset_of!(JOBOBJECT_BASIC_PROCESS_ID_LIST, ProcessIdList) % size_of::() == 0 + ); + + /// Whether the calling process runs inside any job object. + /// + /// A null job handle asks "in *a* job", not "in *this* job". Needed + /// because the code a job-less caller gets from + /// [`query_job_process_ids`] is undocumented. + pub fn is_process_in_job() -> io::Result { + let mut in_job: BOOL = FALSE; + // SAFETY: the current-process pseudo-handle is always valid, a null + // job handle is documented as "any job", and `in_job` is an out-param + // valid for the duration of the call. + cvt(unsafe { IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &raw mut in_job) })?; + Ok(in_job != FALSE) + } + + /// List the processes assigned to `job`, with room for `capacity` of them; + /// `None` means the job the calling process itself belongs to. + /// + /// `None` is the only way to reach one's own job — a process cannot open a + /// handle to it — and it addresses the immediate job when jobs are nested, + /// whose list still covers that job's child jobs. It fails when the caller + /// is in no job at all, so check [`is_process_in_job`] first. Truncation + /// reports nothing but the fact: the counts and returned length are all + /// zero on that path on some Windows 10 builds, so only the caller can + /// pick a retry size. + pub fn query_job_process_ids( + job: Option, + capacity: usize, + ) -> io::Result { + // `max(1)` keeps the buffer at least one whole struct wide. + let mut buffer = vec![0usize; PID_LIST_HEADER_SLOTS + capacity.max(1)]; + let length = u32::try_from(buffer.len() * size_of::()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + let job = job.map_or(std::ptr::null_mut(), |job| job.as_raw_handle()); + // SAFETY: `job` is null or a valid handle for the duration of the + // call; `buffer` is a live `usize` allocation of exactly `length` + // bytes, so it is aligned for the struct and as large as we claim; a + // null return-length pointer is documented as valid. + let result = unsafe { + QueryInformationJobObject( + job, + JobObjectBasicProcessIdList, + buffer.as_mut_ptr().cast(), + length, + std::ptr::null_mut(), + ) + }; + match cvt(result) { + Ok(()) => Ok(JobProcessIds::Complete(process_ids(&buffer))), + // How this info class reports STATUS_BUFFER_OVERFLOW. + Err(error) if error.raw_os_error() == Some(ERROR_MORE_DATA as i32) => { + Ok(JobProcessIds::Truncated) + } + Err(error) => Err(error), + } + } + + fn process_ids(buffer: &[usize]) -> Vec { + debug_assert!(buffer.len() > PID_LIST_HEADER_SLOTS); + // SAFETY: `buffer` is a `usize` allocation at least one whole + // `JOBOBJECT_BASIC_PROCESS_ID_LIST` long (its two counts plus one pid + // slot), so the read is aligned and in bounds, and every field is an + // integer, so any bit pattern read back is a valid value. + let header = unsafe { + buffer + .as_ptr() + .cast::() + .read() + }; + buffer[PID_LIST_HEADER_SLOTS..] + .iter() + .take(header.NumberOfProcessIdsInList as usize) + // Pids are `ULONG_PTR`-wide but always fit a `DWORD`, and zero + // names no process. + .filter_map(|&pid| u32::try_from(pid).ok()) + .filter(|&pid| pid != 0) + .collect() + } + /// Terminate the process behind `process` with the given exit code. pub fn terminate_process(process: BorrowedHandle, exit_code: u32) -> io::Result<()> { // SAFETY: the process handle is valid for the duration of the call; @@ -432,6 +535,84 @@ fn terminate_pid(pid: u32, signal: usize) -> io::Result<()> { } } +/// Pids in the first attempt at listing a job, doubling up to [`JOB_PID_MAX`]. +/// +/// A job whose list does not fit can report a zero size to retry with +/// (Windows 10, notably under WOW64), so the growth cannot be driven by the +/// kernel's numbers. +const JOB_PID_CAPACITY: usize = 64; +const JOB_PID_MAX: usize = 128 * 1024; + +fn job_process_ids(job: Option) -> io::Result> { + let mut capacity = JOB_PID_CAPACITY; + while capacity <= JOB_PID_MAX { + match sys::query_job_process_ids(job, capacity)? { + sys::JobProcessIds::Complete(pids) => return Ok(pids), + sys::JobProcessIds::Truncated => capacity *= 2, + } + } + Err(io::Error::other(format!( + "job process list did not fit in {JOB_PID_MAX} entries" + ))) +} + +/// `members` with `own_pid` present exactly once, at the end, so a terminating +/// signal reaches every other member before it kills this process. +/// +/// Split out because an empty `members` *is* the job-less case, which no test +/// can reproduce by spawning (a child created with `CREATE_BREAKAWAY_FROM_JOB` +/// leaves only its immediate job; an ancestor job still claims it). +fn with_self_last(mut members: Vec, own_pid: u32) -> Vec { + members.retain(|&pid| pid != own_pid); + members.push(own_pid); + members +} + +/// Deliver `signal` to `pids` in order, succeeding if any one delivery did. +/// +/// Reports the first failure rather than the last, since later ones can be +/// consequences of earlier deliveries. +fn send_signal_to_each(pids: &[u32], signal: usize) -> io::Result<()> { + let mut delivered = false; + let mut first_error = None; + for &pid in pids { + match send_signal_to_pid(pid, signal) { + Ok(()) => delivered = true, + Err(error) => { + first_error.get_or_insert(error); + } + } + } + if delivered { + Ok(()) + } else { + Err(first_error.unwrap_or_else(no_such_process)) + } +} + +/// Deliver `signal` (POSIX numbering) to every process in the caller's own +/// process group, emulating `kill(2)` for `pid == 0`. +/// +/// The group is the caller's job object, which is broader than a POSIX process +/// group: a terminal, IDE or CI agent that runs this process inside a job puts +/// everything else it manages in the same group. A process in no job is a +/// group of one. This process is signalled last, so a terminating signal ends +/// it with exit code `128 + signal` instead of returning. +/// +/// Like `kill(0, sig)`, succeeds as soon as one member was signalled: members +/// this process may not touch, and members that exit between enumeration and +/// delivery, do not fail the call. +pub fn send_signal_to_own_group(signal: usize) -> io::Result<()> { + // Fail fast, before enumerating anything. + disposition(signal)?; + let members = if sys::is_process_in_job()? { + job_process_ids(None)? + } else { + Vec::new() + }; + send_signal_to_each(&with_self_last(members, std::process::id()), signal) +} + /// Deliver `signal` (POSIX numbering) to the console process group led by /// `pid` (the process must have been created with `CREATE_NEW_PROCESS_GROUP`, /// e.g. via [`configure_process_group`]). @@ -790,4 +971,76 @@ mod tests { io::ErrorKind::NotFound ); } + + // Nothing below assigns the *current* process to a job: joining one is + // irreversible and process-global, so it would rewrite what every other + // test in this binary sees (cargo runs them on parallel threads) — the same + // hazard that forces the ctrl-forwarding phases into a single test above. + // + // For the same reason, never pass a terminating signal to + // `send_signal_to_own_group` here: this process's group is whatever job the + // test runner provides. That path is covered in tests/by-util/test_kill.rs, + // inside a job the test creates itself. + + #[test] + fn with_self_last_puts_this_process_exactly_once_at_the_end() { + // Empty members *is* the job-less case. + assert_eq!(with_self_last(vec![], 7), vec![7]); + assert_eq!(with_self_last(vec![3, 7], 7), vec![3, 7]); + assert_eq!(with_self_last(vec![7, 3, 5], 7), vec![3, 5, 7]); + // Listed twice, signalled once, so the loop cannot kill itself early. + assert_eq!(with_self_last(vec![7, 3, 7], 7), vec![3, 7]); + } + + /// Also the canary for `QueryInformationJobObject(NULL, ...)`: if the + /// null-handle query were ever denied inside a job we do not own, this + /// fails on CI instead of `kill 0` failing in the field. + #[test] + fn send_signal_to_own_group_probe_and_ignore_are_no_ops() { + let mut child = spawn_child(); + send_signal_to_own_group(0).unwrap(); + send_signal_to_own_group(17).unwrap(); + assert!( + child.try_wait().unwrap().is_none(), + "the group probe terminated a process" + ); + child.kill().unwrap(); + child.wait().unwrap(); + } + + #[test] + fn send_signal_to_own_group_rejects_invalid_signal_before_enumerating() { + assert_eq!( + send_signal_to_own_group(64).unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + } + + /// Against a job whose membership we control; the caller's own job (`None`) + /// is whatever the machine running the tests happens to provide. + #[test] + fn job_process_ids_lists_members_and_reports_truncation() { + let job = Job::new().unwrap(); + let mut children = [spawn_child(), spawn_child()]; + for child in &children { + job.assign(child).unwrap(); + } + let handle = job.0.as_handle(); + + let pids = job_process_ids(Some(handle)).unwrap(); + for child in &children { + assert!(pids.contains(&child.id())); + } + + // Two processes cannot fit a one-pid buffer. + assert!(matches!( + sys::query_job_process_ids(Some(handle), 1).unwrap(), + sys::JobProcessIds::Truncated + )); + + for child in &mut children { + child.kill().unwrap(); + child.wait().unwrap(); + } + } } diff --git a/tests/by-util/test_kill.rs b/tests/by-util/test_kill.rs index c304e654422..eac6b7dcf6f 100644 --- a/tests/by-util/test_kill.rs +++ b/tests/by-util/test_kill.rs @@ -4,9 +4,15 @@ // file that was distributed with this source code. // spell-checker:ignore IAMNOTASIGNAL RTMAX RTMIN SIGIO SIGRTMAX GHSA CHLD SIGSTOP taskkill unreaped use regex::Regex; +#[cfg(windows)] +use std::io::Write; #[cfg(unix)] use std::os::unix::process::ExitStatusExt; +#[cfg(windows)] +use std::process::Stdio; use std::process::{Child, Command, ExitStatus}; +#[cfg(windows)] +use uucore::process::Job; #[cfg(any(target_os = "linux", target_os = "android"))] use uucore::signals::realtime_signal_bounds; use uutests::new_ucmd; @@ -69,6 +75,66 @@ impl Drop for Target { } } +/// A `cmd.exe` in a freshly created Job object, waiting for a command line on +/// its stdin pipe. +/// +/// The only safe way to run a *terminating* `kill 0` from a test: `kill 0` +/// signals the killer's immediate job, which here is `job` and nothing else, so +/// it cannot reach the test runner, another test's children, nextest's per-test +/// job or a CI agent — the ambient job is `job`'s *parent*, and a parent's +/// members are not in a child job's list. +/// +/// The gate is what makes that true rather than merely likely: `cmd` gets no +/// command until `assign` has succeeded, so the window between `CreateProcess` +/// and `AssignProcessToJobObject` contains nothing that could signal. Fail-safe +/// too — if anything panics before [`JobShell::run`], the pipe closes, `cmd` +/// sees EOF and exits having executed nothing. +#[cfg(windows)] +struct JobShell { + job: Job, + shell: Child, +} + +#[cfg(windows)] +impl JobShell { + fn new() -> Self { + let job = Job::new().expect("cannot create job object"); + let shell = Command::new("cmd") + // `/d` skips the AutoRun registry command, so a machine that has + // one cannot run it inside this job or write to the stderr the + // assertions quote. + .args(["/d", "/q"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .expect("cannot spawn cmd"); + job.assign(&shell) + .expect("cannot assign cmd to the fresh job"); + Self { job, shell } + } + + /// So a group signal from inside reaches `child`. + fn adopt(&self, child: &Child) { + self.job + .assign(child) + .expect("cannot assign process to the fresh job"); + } + + /// Closes stdin afterwards so cmd exits at EOF rather than blocking forever + /// if the command fails to kill it. + fn run(mut self, command: &str) -> (Option, String) { + let mut stdin = self.shell.stdin.take().expect("cmd has no stdin pipe"); + write!(stdin, "{command}\r\n").expect("cannot write command"); + drop(stdin); + let output = self.shell.wait_with_output().expect("cannot wait on cmd"); + ( + output.status.code(), + String::from_utf8_lossy(&output.stderr).into_owned(), + ) + } +} + #[test] fn test_invalid_arg() { new_ucmd!().arg("--definitely-invalid").fails_with_code(1); @@ -607,20 +673,29 @@ fn test_kill_signal_zero_nonexistent() { new_ucmd!().arg("-0").arg("999999999").fails(); } -#[cfg(unix)] #[test] fn test_kill_signal_zero_current_process_group() { - // kill -0 0 should succeed (checks current process group) + // Signal 0 never terminates anything (windows opens group members with + // SYNCHRONIZE only), so this is safe un-isolated. It proves only that the + // enumeration does not error: probing yourself always succeeds, so it would + // pass on an empty pid list too. new_ucmd!().arg("-0").arg("0").succeeds(); } #[cfg(windows)] #[test] fn test_kill_windows_help_has_platform_notes() { + // Every asserted phrase sits entirely inside one `kill-after-help-windows` + // continuation line: clap's `wrap_help` re-wraps lines longer than the + // terminal width but never joins short ones, so the .ftl line breaks + // survive verbatim and these assertions cannot straddle a break. new_ucmd!() .arg("--help") .succeeds() - .stdout_contains("Windows notes"); + .stdout_contains("Windows notes") + .stdout_contains("PID 0 targets the Job object") + .stdout_contains("Outside a job") + .stdout_contains("a Job object is usually not yours"); } #[cfg(windows)] @@ -660,23 +735,58 @@ fn test_kill_windows_stop_is_rejected() { #[cfg(windows)] #[test] -fn test_kill_windows_process_groups_unsupported() { - new_ucmd!() - .args(&["-0", "0"]) - .fails_with_code(1) - .stderr_contains("process groups are not supported"); +fn test_kill_windows_negative_pid_unsupported() { + // `-1` is the dangerous one on unix ("every process you may signal"); on + // windows it must be inert, and `u32::try_from` fails before any Win32 call. new_ucmd!() .args(&["-9", "-1"]) .fails_with_code(1) - .stderr_contains("process groups are not supported"); - let target = Target::new(); + .stderr_contains("a negative PID"); + + let mut target = Target::new(); new_ucmd!() .arg("--") .arg(format!("-{}", target.pid())) .fails_with_code(1) - .stderr_contains("process groups are not supported"); + .stderr_contains("a negative PID"); + target.assert_alive(); } +/// `kill 0` must reach *other* members of the job, not just the caller. +/// +/// The victim is the proof: it is neither the killer's parent nor its child, so +/// the only path by which it can die is the job pid list. An implementation +/// that regressed to "signal only myself" would still make cmd exit 137, so the +/// exit code alone would not catch it. +#[cfg(windows)] +#[test] +fn test_kill_windows_pid_zero_terminates_the_whole_job() { + for (args, signal) in [("-9 0", 9), ("0", 15)] { + let shell = JobShell::new(); + let mut victim = Target::new(); + shell.adopt(&victim.child); + + // Only now can anything in the job run. + let (code, stderr) = shell.run(&format!("\"{}\" kill {args}", get_tests_binary())); + + // Read cmd's status first, so a broken kill surfaces its own message + // rather than only a 30-second victim timeout below. + assert_eq!( + code, + Some(128 + signal), + "cmd survived `kill {args}`; kill said: {stderr}" + ); + victim.assert_signaled(signal); + } +} + +// The "not in a job" fallback has no integration test on purpose: nothing can +// guarantee a spawned process is job-less. `CREATE_BREAKAWAY_FROM_JOB` detaches +// only from the immediate job, so under a nested chain (`cargo nextest` inside +// `cargo`) the child stays in an ancestor job and `kill -9 0` terminates the +// test runner — an earlier version of this test did exactly that. Covered by +// `with_self_last` in uucore instead, whose empty-member case is that case. + #[cfg(windows)] #[test] fn test_kill_windows_nonexistent_pid_no_such_process() {