use std::process::{Child, Command};
pub struct ProcessTree {
#[cfg(windows)]
job: Option<job::ProcessJob>,
}
impl ProcessTree {
pub fn prepare(cmd: &mut Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED);
}
#[cfg(not(windows))]
let _ = cmd;
}
pub(crate) fn start(child: &mut Child) -> Result<Self, String> {
#[cfg(windows)]
{
let job = match job::ProcessJob::assign(child) {
Ok(job) => Some(job),
Err(error) => {
tracing::warn!("process tree not contained, falling back to taskkill: {error}");
None
}
};
if let Err(error) = job::resume(child.id()) {
if let Some(job) = &job {
job.terminate();
}
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
Ok(Self { job })
}
#[cfg(not(windows))]
{
let _ = child;
Ok(Self {})
}
}
#[cfg(not(unix))]
pub fn spawn_contained(command: &mut Command) -> std::io::Result<(Child, Self)> {
Self::prepare(command);
let mut child = command.spawn()?;
let tree = Self::start(&mut child).map_err(std::io::Error::other)?;
Ok((child, tree))
}
pub(crate) fn kill(&self, child: &mut Child) {
#[cfg(unix)]
{
if let Ok(pgid) = crate::ipc::process::group_target(child.id()) {
unsafe { libc::killpg(pgid, libc::SIGKILL) };
}
}
#[cfg(windows)]
match &self.job {
Some(job) => job.terminate(),
None => {
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &child.id().to_string()])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
let _ = child.kill();
}
pub(crate) fn release(self) {
#[cfg(windows)]
if let Some(job) = &self.job {
job.release();
}
}
}
type ShellPipe = Option<Box<dyn std::io::Read + Send>>;
fn boxed(pipe: impl std::io::Read + Send + 'static) -> Box<dyn std::io::Read + Send> {
Box::new(pipe)
}
pub struct StartedShell {
pub child: ShellChild,
pub stdout: ShellPipe,
pub stderr: ShellPipe,
pub notice: Option<&'static str>,
}
pub enum ShellChild {
Direct {
child: Child,
tree: ProcessTree,
},
#[cfg(unix)]
Helped(crate::shell::containment::HelpedChild),
}
impl ShellChild {
pub fn start(cmd: &mut Command) -> Result<StartedShell, String> {
#[cfg(unix)]
let notice = {
use crate::shell::containment::{HelperSpawn, spawn_shell};
match spawn_shell(cmd) {
HelperSpawn::Helped {
child,
stdout,
stderr,
} => {
return Ok(StartedShell {
child: Self::Helped(child),
stdout: Some(boxed(stdout)),
stderr: Some(boxed(stderr)),
notice: None,
});
}
HelperSpawn::Direct(notice) => notice,
}
};
#[cfg(not(unix))]
let notice = None;
#[cfg(unix)]
crate::shell::containment::lead_new_session(cmd);
let mut child = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|error| error.to_string())?;
let tree = ProcessTree::start(&mut child)?;
let stdout = child.stdout.take().map(boxed);
let stderr = child.stderr.take().map(boxed);
Ok(StartedShell {
child: Self::Direct { child, tree },
stdout,
stderr,
notice,
})
}
pub fn id(&self) -> u32 {
match self {
Self::Direct { child, .. } => child.id(),
#[cfg(unix)]
Self::Helped(child) => child.id(),
}
}
pub fn try_wait(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
match self {
Self::Direct { child, .. } => child.try_wait(),
#[cfg(unix)]
Self::Helped(child) => child.try_wait(),
}
}
pub fn kill(&mut self) {
match self {
Self::Direct { child, tree } => tree.kill(child),
#[cfg(unix)]
Self::Helped(child) => child.kill(),
}
}
pub fn wait(&mut self) {
match self {
Self::Direct { child, .. } => {
let _ = child.wait();
}
#[cfg(unix)]
Self::Helped(child) => child.wait(),
}
}
pub fn release(self) -> Option<&'static str> {
match self {
Self::Direct { tree, .. } => {
tree.release();
None
}
#[cfg(unix)]
Self::Helped(child) => {
(!child.release()).then_some(crate::shell::containment::RELEASE_NOTICE)
}
}
}
pub fn lost_notice(&self) -> Option<&'static str> {
match self {
Self::Direct { .. } => None,
#[cfg(unix)]
Self::Helped(child) => child.notice(),
}
}
}
#[cfg(windows)]
pub fn contain_requested_session() {
use std::io::Write;
const SESSION_ENV: &str = "LEAN_CTX_SESSION_CONTAINMENT";
const SESSION_ACK: &[u8] = b"{\"leanctx_session_containment\":1}\n";
let mut args = std::env::args_os();
let _program = args.next();
if args.next().is_none_or(|first| first != "engine")
|| std::env::var_os(SESSION_ENV).is_none_or(|value| value != "1")
{
return;
}
unsafe { std::env::remove_var(SESSION_ENV) };
let Ok(session) = job::ProcessJob::assign_current_process() else {
return;
};
std::mem::forget(session);
let mut stdout = std::io::stdout().lock();
let _ = stdout.write_all(SESSION_ACK).and_then(|()| stdout.flush());
}
#[cfg(windows)]
pub(crate) mod job {
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
use windows_sys::Win32::System::JobObjects::{
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JobObjectExtendedLimitInformation, SetInformationJobObject,
};
pub(crate) struct ProcessJob(HANDLE);
impl ProcessJob {
pub(crate) fn assign(child: &std::process::Child) -> Result<Self, String> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW,
};
let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if handle.is_null() {
return Err(format!(
"failed to create job object: {}",
std::io::Error::last_os_error()
));
}
let job = Self(handle);
job.set_limit_flags(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
.map_err(|error| format!("failed to configure job object: {error}"))?;
let assigned = unsafe { AssignProcessToJobObject(job.0, child.as_raw_handle()) };
if assigned == 0 {
return Err(format!(
"failed to assign process {} to its job: {}",
child.id(),
std::io::Error::last_os_error()
));
}
Ok(job)
}
pub(crate) fn assign_current_process() -> Result<Self, String> {
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW,
};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if handle.is_null() {
return Err(format!(
"failed to create job object: {}",
std::io::Error::last_os_error()
));
}
let job = Self(handle);
job.set_limit_flags(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
.map_err(|error| format!("failed to configure job object: {error}"))?;
let assigned = unsafe { AssignProcessToJobObject(job.0, GetCurrentProcess()) };
if assigned == 0 {
return Err(format!(
"failed to assign this process to its session job: {}",
std::io::Error::last_os_error()
));
}
Ok(job)
}
pub(crate) fn terminate(&self) {
unsafe {
windows_sys::Win32::System::JobObjects::TerminateJobObject(self.0, 1);
}
}
pub(crate) fn release(&self) {
if let Err(error) = self.set_limit_flags(0) {
tracing::warn!("failed to release job object: {error}");
}
}
fn set_limit_flags(&self, flags: u32) -> std::io::Result<()> {
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
limits.BasicLimitInformation.LimitFlags = flags;
let configured = unsafe {
SetInformationJobObject(
self.0,
JobObjectExtendedLimitInformation,
std::ptr::from_ref(&limits).cast(),
std::mem::size_of_val(&limits) as u32,
)
};
if configured == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
}
impl Drop for ProcessJob {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
pub(crate) fn resume(process_id: u32) -> Result<(), String> {
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
};
use windows_sys::Win32::System::Threading::{
OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
};
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return Err(format!(
"failed to inspect suspended process {process_id} threads: {}",
std::io::Error::last_os_error()
));
}
let mut entry = THREADENTRY32 {
dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
..THREADENTRY32::default()
};
let mut has_entry = unsafe { Thread32First(snapshot, &raw mut entry) } != 0;
let mut resumed = false;
let result = loop {
if !has_entry {
break if resumed {
Ok(())
} else {
Err(format!(
"suspended process {process_id} exposed no resumable thread"
))
};
}
if entry.th32OwnerProcessID == process_id {
let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
if thread.is_null() {
break Err(format!(
"failed to open suspended process {process_id} thread: {}",
std::io::Error::last_os_error()
));
}
let resume_result = unsafe { ResumeThread(thread) };
unsafe { CloseHandle(thread) };
if resume_result == u32::MAX {
break Err(format!(
"failed to resume process {process_id} thread: {}",
std::io::Error::last_os_error()
));
}
resumed = true;
}
has_entry = unsafe { Thread32Next(snapshot, &raw mut entry) } != 0;
};
unsafe { CloseHandle(snapshot) };
result
}
}
#[cfg(all(test, unix))]
mod tests {
use super::ProcessTree;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
fn alive(pid: i32) -> bool {
unsafe { libc::kill(pid, 0) == 0 }
}
#[test]
fn kill_ends_grandchildren_not_just_the_shell() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "sleep 30 & echo $!; wait"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.process_group(0);
ProcessTree::prepare(&mut cmd);
let mut child = cmd.spawn().expect("spawn sh");
let tree = ProcessTree::start(&mut child).expect("start tree");
let mut line = String::new();
std::io::BufRead::read_line(
&mut std::io::BufReader::new(child.stdout.take().expect("stdout")),
&mut line,
)
.expect("read grandchild pid");
let grandchild: i32 = line.trim().parse().expect("grandchild pid");
assert!(
alive(grandchild),
"grandchild must be running before the kill"
);
tree.kill(&mut child);
let _ = child.wait();
let deadline = Instant::now() + Duration::from_secs(5);
while alive(grandchild) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert!(!alive(grandchild), "kill must reach the shell's grandchild");
}
#[test]
fn release_after_normal_exit_is_a_no_op() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "exit 3"]).process_group(0);
ProcessTree::prepare(&mut cmd);
let mut child = cmd.spawn().expect("spawn sh");
let tree = ProcessTree::start(&mut child).expect("start tree");
let status = child.wait().expect("wait");
tree.release();
assert_eq!(status.code(), Some(3));
}
}