use anyhow::{Context as _, Result};
use std::process::Stdio;
pub struct Child {
process: smol::process::Child,
#[cfg(windows)]
job: Option<windows_job::JobObject>,
}
impl std::ops::Deref for Child {
type Target = smol::process::Child;
fn deref(&self) -> &Self::Target {
&self.process
}
}
impl std::ops::DerefMut for Child {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.process
}
}
impl Child {
#[cfg(not(windows))]
pub fn spawn(
mut command: std::process::Command,
stdin: Stdio,
stdout: Stdio,
stderr: Stdio,
) -> Result<Self> {
crate::set_pre_exec_to_start_new_session(&mut command);
let mut command = smol::process::Command::from(command);
let process = command
.stdin(stdin)
.stdout(stdout)
.stderr(stderr)
.spawn()
.with_context(|| {
format!(
"failed to spawn command {}",
crate::redact::redact_command(&format!("{command:?}"))
)
})?;
Ok(Self { process })
}
#[cfg(windows)]
pub fn spawn(
command: std::process::Command,
stdin: Stdio,
stdout: Stdio,
stderr: Stdio,
) -> Result<Self> {
let mut command = smol::process::Command::from(command);
let process = command
.stdin(stdin)
.stdout(stdout)
.stderr(stderr)
.spawn()
.with_context(|| {
format!(
"failed to spawn command {}",
crate::redact::redact_command(&format!("{command:?}"))
)
})?;
let job = windows_job::JobObject::new()
.and_then(|job| {
job.assign_process(process.id())?;
Ok(job)
})
.map_err(|error| {
log::error!("failed to assign spawned process to a job object: {error:#}");
})
.ok();
Ok(Self { process, job })
}
pub async fn output(self) -> Result<std::process::Output> {
Ok(self.process.output().await?)
}
#[cfg(not(windows))]
pub fn kill(&mut self) -> Result<()> {
let pid = self.process.id();
unsafe {
libc::killpg(pid as i32, libc::SIGKILL);
}
Ok(())
}
#[cfg(windows)]
pub fn kill(&mut self) -> Result<()> {
if let Some(job) = &self.job {
job.terminate()
} else {
self.process.kill()?;
Ok(())
}
}
}
#[cfg(windows)]
mod windows_job {
use crate::ResultExt as _;
use anyhow::{Context as _, Result};
use windows::Win32::{
Foundation::{CloseHandle, HANDLE},
System::{
JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject,
},
Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE},
},
};
pub(crate) struct JobObject(HANDLE);
unsafe impl Send for JobObject {}
unsafe impl Sync for JobObject {}
impl JobObject {
pub(crate) fn new() -> Result<Self> {
unsafe {
let job =
Self(CreateJobObjectW(None, None).context("failed to create job object")?);
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(
job.0,
JobObjectExtendedLimitInformation,
&info as *const _ as *const _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
.context("failed to set job object limits")?;
Ok(job)
}
}
pub(crate) fn assign_process(&self, pid: u32) -> Result<()> {
unsafe {
let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
.context("failed to open process")?;
let result = AssignProcessToJobObject(self.0, process)
.context("failed to assign process to job object");
CloseHandle(process).log_err();
result
}
}
pub(crate) fn terminate(&self) -> Result<()> {
unsafe { TerminateJobObject(self.0, 1).context("failed to terminate job object") }
}
}
impl Drop for JobObject {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0).log_err();
}
}
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::*;
use std::time::{Duration, Instant};
fn spawn_process_tree(temp_dir: &std::path::Path) -> (Child, u32) {
let pid_file = temp_dir.join("grandchild_pid");
let mut command = std::process::Command::new("powershell.exe");
command.args(["-NoProfile", "-Command"]).arg(format!(
"$p = Start-Process -FilePath ping.exe -ArgumentList @('-n','60','127.0.0.1') -PassThru -WindowStyle Hidden; \
Set-Content -LiteralPath '{}' -Value $p.Id; \
Wait-Process -Id $p.Id",
pid_file.display()
));
let child = Child::spawn(command, Stdio::null(), Stdio::null(), Stdio::null())
.expect("failed to spawn powershell");
let deadline = Instant::now() + Duration::from_secs(5);
let grandchild_pid = loop {
if let Ok(contents) = std::fs::read_to_string(&pid_file)
&& let Ok(pid) = contents.trim().parse::<u32>()
{
break pid;
}
assert!(
Instant::now() < deadline,
"timed out waiting for grandchild pid file"
);
std::thread::sleep(Duration::from_millis(50));
};
assert!(
process_is_alive(grandchild_pid),
"grandchild should be alive after spawning"
);
(child, grandchild_pid)
}
fn process_is_alive(pid: u32) -> bool {
use windows::Win32::{
Foundation::{CloseHandle, STILL_ACTIVE},
System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
},
};
unsafe {
let Ok(handle) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else {
return false;
};
let mut exit_code = 0u32;
let alive = GetExitCodeProcess(handle, &mut exit_code).is_ok()
&& exit_code == STILL_ACTIVE.0 as u32;
CloseHandle(handle).expect("failed to close process handle");
alive
}
}
fn assert_process_exits(pid: u32, message: &str) {
let deadline = Instant::now() + Duration::from_secs(2);
while process_is_alive(pid) {
assert!(Instant::now() < deadline, "{message} (pid {pid})");
std::thread::sleep(Duration::from_millis(100));
}
}
#[test]
fn test_kill_terminates_grandchildren() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut child, grandchild_pid) = spawn_process_tree(temp_dir.path());
child.kill().expect("failed to kill child");
assert_process_exits(
grandchild_pid,
"grandchild should be terminated after killing the child",
);
}
#[test]
fn test_drop_terminates_grandchildren() {
let temp_dir = tempfile::tempdir().unwrap();
let (child, grandchild_pid) = spawn_process_tree(temp_dir.path());
drop(child);
assert_process_exits(
grandchild_pid,
"grandchild should be terminated after dropping the child",
);
}
}