use std::process::{Child, Command as OsCommand};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
#[derive(Debug)]
pub(crate) struct KillGroup {
pid: AtomicU32,
reap_on_drop: AtomicBool,
#[cfg(windows)]
job: windows_job::Job,
}
impl KillGroup {
pub(crate) fn prepare(cmd: &mut OsCommand) -> Self {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
#[cfg(windows)]
let _ = cmd;
Self {
pid: AtomicU32::new(0),
reap_on_drop: AtomicBool::new(false),
#[cfg(windows)]
job: windows_job::Job::create(),
}
}
pub(crate) fn reap_on_drop(&self) {
self.reap_on_drop.store(true, Ordering::Relaxed);
}
pub(crate) fn adopt(&self, child: &Child) {
self.pid.store(child.id(), Ordering::Relaxed);
#[cfg(windows)]
self.job.adopt(child);
}
pub(crate) fn kill(&self) {
let pid = self.pid.load(Ordering::Relaxed);
if pid == 0 {
return;
}
#[cfg(windows)]
{
if self.job.terminate() {
return;
}
taskkill_tree(pid);
}
#[cfg(unix)]
{
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
}
}
impl Drop for KillGroup {
fn drop(&mut self) {
if self.reap_on_drop.load(Ordering::Relaxed) {
self.kill();
}
}
}
#[cfg(windows)]
fn taskkill_tree(pid: u32) {
use std::process::Stdio;
let _ = OsCommand::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(windows)]
mod windows_job {
use std::os::windows::io::AsRawHandle;
use std::process::Child;
use std::sync::atomic::{AtomicBool, Ordering};
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, TerminateJobObject,
};
pub(super) struct Job {
handle: HANDLE,
holds_child: AtomicBool,
}
unsafe impl Send for Job {}
unsafe impl Sync for Job {}
impl std::fmt::Debug for Job {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Job")
.field("available", &!self.handle.is_null())
.field("holds_child", &self.holds_child.load(Ordering::Relaxed))
.finish()
}
}
static CREATE_WARNED: AtomicBool = AtomicBool::new(false);
static ASSIGN_WARNED: AtomicBool = AtomicBool::new(false);
impl Job {
pub(super) fn create() -> Self {
let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if handle.is_null() && !CREATE_WARNED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"could not create a job object; process trees will be killed with taskkill instead, which costs a process spawn per kill. Reported once."
);
}
Self {
handle,
holds_child: AtomicBool::new(false),
}
}
pub(super) fn adopt(&self, child: &Child) {
if self.handle.is_null() {
return;
}
let assigned =
unsafe { AssignProcessToJobObject(self.handle, child.as_raw_handle() as HANDLE) };
if assigned == 0 {
if !ASSIGN_WARNED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"could not assign a child to a job object; its tree will be killed with taskkill instead. Reported once."
);
}
return;
}
self.holds_child.store(true, Ordering::Relaxed);
}
pub(super) fn terminate(&self) -> bool {
if !self.holds_child.load(Ordering::Relaxed) {
return false;
}
unsafe { TerminateJobObject(self.handle, 1) != 0 }
}
}
impl Drop for Job {
fn drop(&mut self) {
if self.handle.is_null() {
return;
}
unsafe {
CloseHandle(self.handle);
}
}
}
}
pub(crate) fn shell_command(command_line: &str) -> OsCommand {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
let mut c = OsCommand::new("cmd.exe");
c.raw_arg("/c ").raw_arg(command_line);
c
}
#[cfg(unix)]
{
let mut c = OsCommand::new("/bin/sh");
c.arg("-c").arg(command_line);
c
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Stdio;
#[test]
fn shell_command_runs_a_trivial_command() {
let output = shell_command("echo shell-tunnel")
.stdin(Stdio::null())
.output()
.expect("shell should be available");
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("shell-tunnel"));
}
#[test]
fn a_quoted_command_reaches_the_shell_intact() {
let output = shell_command(r#"echo ["quoted"]"#)
.stdin(Stdio::null())
.output()
.expect("shell should be available");
let text = String::from_utf8_lossy(&output.stdout);
#[cfg(windows)]
let expected = r#"["quoted"]"#;
#[cfg(unix)]
let expected = "[quoted]";
assert!(
text.contains(expected),
"the quote must reach the shell as a quote; wanted {expected:?}, got {text:?}"
);
assert!(
!text.contains(r#"\""#),
"a backslash the caller never wrote must not appear: {text:?}"
);
}
#[test]
fn a_quoted_path_is_one_argument() {
let root = env!("CARGO_MANIFEST_DIR");
#[cfg(windows)]
let line = format!(r#"dir /b "{root}\Cargo.toml""#);
#[cfg(unix)]
let line = format!(r#"ls "{root}/Cargo.toml""#);
let output = shell_command(&line)
.stdin(Stdio::null())
.output()
.expect("shell should be available");
assert!(
output.status.success(),
"a quoted path must be understood: {:?} / {:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn killing_a_group_whose_child_already_exited_is_harmless() {
let mut cmd = shell_command("exit 0");
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let group = KillGroup::prepare(&mut cmd);
let mut child = cmd.spawn().expect("spawn");
group.adopt(&child);
let _ = child.wait();
group.kill();
}
#[test]
fn reaping_on_drop_is_opt_in_and_it_works() {
use std::io::Read;
use std::sync::mpsc;
use std::time::Duration;
#[cfg(windows)]
let line = r#"start /b powershell -NoProfile -Command "Start-Sleep -Seconds 25""#;
#[cfg(unix)]
let line = "sleep 25 &";
fn run(reap: bool, line: &str, wait: Duration) -> bool {
let mut cmd = shell_command(line);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let group = KillGroup::prepare(&mut cmd);
if reap {
group.reap_on_drop();
}
let mut child = cmd.spawn().expect("spawn");
group.adopt(&child);
let mut pipe = child.stdout.take().expect("piped");
let _ = child.wait();
drop(group);
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut sink = Vec::new();
let _ = pipe.read_to_end(&mut sink);
let _ = tx.send(());
});
rx.recv_timeout(wait).is_ok()
}
assert!(
run(true, line, Duration::from_secs(20)),
"a group asked to reap on drop must take the tree with it"
);
assert!(
!run(false, line, Duration::from_secs(5)),
"a group not asked to reap must leave the background process running: that default is the contract, not an oversight"
);
}
#[cfg(windows)]
#[test]
fn the_taskkill_fallback_still_runs() {
let mut child = shell_command("exit 0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn");
let pid = child.id();
let _ = child.wait();
taskkill_tree(pid);
}
#[test]
fn a_group_that_never_adopted_a_child_kills_nothing() {
let mut cmd = shell_command("exit 0");
let group = KillGroup::prepare(&mut cmd);
group.kill();
}
#[test]
fn a_group_kills_a_background_process_the_command_left_behind() {
use std::io::Read;
use std::sync::mpsc;
use std::time::Duration;
#[cfg(windows)]
let line = r#"start /b powershell -NoProfile -Command "Start-Sleep -Seconds 120""#;
#[cfg(unix)]
let line = "sleep 120 &";
let mut cmd = shell_command(line);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let group = KillGroup::prepare(&mut cmd);
let mut child = cmd.spawn().expect("spawn");
group.adopt(&child);
let mut pipe = child.stdout.take().expect("piped");
let _ = child.wait();
group.kill();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut sink = Vec::new();
let _ = pipe.read_to_end(&mut sink);
let _ = tx.send(());
});
assert!(
rx.recv_timeout(Duration::from_secs(20)).is_ok(),
"the pipe never reached EOF, so something in the tree outlived the kill"
);
}
}