1use std::process::{Child, Command};
17
18pub struct ProcessTree {
21 #[cfg(windows)]
22 job: Option<job::ProcessJob>,
23}
24
25impl ProcessTree {
26 pub fn prepare(cmd: &mut Command) {
30 #[cfg(windows)]
31 {
32 use std::os::windows::process::CommandExt;
33 cmd.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED);
34 }
35 #[cfg(not(windows))]
36 let _ = cmd;
37 }
38
39 pub(crate) fn start(child: &mut Child) -> Result<Self, String> {
46 #[cfg(windows)]
47 {
48 let job = match job::ProcessJob::assign(child) {
49 Ok(job) => Some(job),
50 Err(error) => {
51 tracing::warn!("process tree not contained, falling back to taskkill: {error}");
52 None
53 }
54 };
55 if let Err(error) = job::resume(child.id()) {
56 if let Some(job) = &job {
57 job.terminate();
58 }
59 let _ = child.kill();
60 let _ = child.wait();
61 return Err(error);
62 }
63 Ok(Self { job })
64 }
65 #[cfg(not(windows))]
66 {
67 let _ = child;
68 Ok(Self {})
69 }
70 }
71
72 #[cfg(not(unix))]
82 pub fn spawn_contained(command: &mut Command) -> std::io::Result<(Child, Self)> {
83 Self::prepare(command);
84 let mut child = command.spawn()?;
85 let tree = Self::start(&mut child).map_err(std::io::Error::other)?;
86 Ok((child, tree))
87 }
88
89 pub(crate) fn kill(&self, child: &mut Child) {
96 #[cfg(unix)]
97 {
98 let pgid = child.id() as libc::pid_t;
99 if pgid > 0 {
100 unsafe { libc::killpg(pgid, libc::SIGKILL) };
102 }
103 }
104 #[cfg(windows)]
105 match &self.job {
106 Some(job) => job.terminate(),
107 None => {
108 let _ = std::process::Command::new("taskkill")
109 .args(["/F", "/T", "/PID", &child.id().to_string()])
110 .stdin(std::process::Stdio::null())
111 .stdout(std::process::Stdio::null())
112 .stderr(std::process::Stdio::null())
113 .status();
114 }
115 }
116 let _ = child.kill();
117 }
118
119 pub(crate) fn release(self) {
126 #[cfg(windows)]
127 if let Some(job) = &self.job {
128 job.release();
129 }
130 }
131}
132
133type ShellPipe = Option<Box<dyn std::io::Read + Send>>;
135
136fn boxed(pipe: impl std::io::Read + Send + 'static) -> Box<dyn std::io::Read + Send> {
137 Box::new(pipe)
138}
139
140pub struct StartedShell {
143 pub child: ShellChild,
144 pub stdout: ShellPipe,
145 pub stderr: ShellPipe,
146 pub notice: Option<&'static str>,
148}
149
150pub enum ShellChild {
157 Direct {
158 child: Child,
159 tree: ProcessTree,
160 },
161 #[cfg(unix)]
162 Helped(crate::shell::containment::HelpedChild),
163}
164
165impl ShellChild {
166 pub fn start(cmd: &mut Command) -> Result<StartedShell, String> {
170 #[cfg(unix)]
171 let notice = {
172 use crate::shell::containment::{HelperSpawn, spawn_shell};
173 match spawn_shell(cmd) {
174 HelperSpawn::Helped {
175 child,
176 stdout,
177 stderr,
178 } => {
179 return Ok(StartedShell {
180 child: Self::Helped(child),
181 stdout: Some(boxed(stdout)),
182 stderr: Some(boxed(stderr)),
183 notice: None,
184 });
185 }
186 HelperSpawn::Direct(notice) => notice,
187 }
188 };
189 #[cfg(not(unix))]
190 let notice = None;
191 #[cfg(unix)]
192 crate::shell::containment::lead_new_session(cmd);
193 let mut child = cmd
194 .stdout(std::process::Stdio::piped())
195 .stderr(std::process::Stdio::piped())
196 .spawn()
197 .map_err(|error| error.to_string())?;
198 let tree = ProcessTree::start(&mut child)?;
199 let stdout = child.stdout.take().map(boxed);
200 let stderr = child.stderr.take().map(boxed);
201 Ok(StartedShell {
202 child: Self::Direct { child, tree },
203 stdout,
204 stderr,
205 notice,
206 })
207 }
208
209 pub fn id(&self) -> u32 {
211 match self {
212 Self::Direct { child, .. } => child.id(),
213 #[cfg(unix)]
214 Self::Helped(child) => child.id(),
215 }
216 }
217
218 pub fn try_wait(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
220 match self {
221 Self::Direct { child, .. } => child.try_wait(),
222 #[cfg(unix)]
223 Self::Helped(child) => child.try_wait(),
224 }
225 }
226
227 pub fn kill(&mut self) {
230 match self {
231 Self::Direct { child, tree } => tree.kill(child),
232 #[cfg(unix)]
233 Self::Helped(child) => child.kill(),
234 }
235 }
236
237 pub fn wait(&mut self) {
239 match self {
240 Self::Direct { child, .. } => {
241 let _ = child.wait();
242 }
243 #[cfg(unix)]
244 Self::Helped(child) => child.wait(),
245 }
246 }
247
248 pub fn release(self) -> Option<&'static str> {
255 match self {
256 Self::Direct { tree, .. } => {
257 tree.release();
258 None
259 }
260 #[cfg(unix)]
261 Self::Helped(child) => {
262 (!child.release()).then_some(crate::shell::containment::RELEASE_NOTICE)
263 }
264 }
265 }
266
267 pub fn lost_notice(&self) -> Option<&'static str> {
272 match self {
273 Self::Direct { .. } => None,
274 #[cfg(unix)]
275 Self::Helped(child) => child.notice(),
276 }
277 }
278}
279
280#[cfg(windows)]
297pub fn contain_requested_session() {
298 use std::io::Write;
299 const SESSION_ENV: &str = "LEAN_CTX_SESSION_CONTAINMENT";
300 const SESSION_ACK: &[u8] = b"{\"leanctx_session_containment\":1}\n";
301
302 let mut args = std::env::args_os();
303 let _program = args.next();
304 if args.next().is_none_or(|first| first != "engine")
305 || std::env::var_os(SESSION_ENV).is_none_or(|value| value != "1")
306 {
307 return;
308 }
309 unsafe { std::env::remove_var(SESSION_ENV) };
311 let Ok(session) = job::ProcessJob::assign_current_process() else {
312 return;
313 };
314 std::mem::forget(session);
317 let mut stdout = std::io::stdout().lock();
318 let _ = stdout.write_all(SESSION_ACK).and_then(|()| stdout.flush());
319}
320
321#[cfg(windows)]
322pub(crate) mod job {
323 use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
324 use windows_sys::Win32::System::JobObjects::{
325 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
326 JobObjectExtendedLimitInformation, SetInformationJobObject,
327 };
328
329 pub(crate) struct ProcessJob(HANDLE);
331
332 impl ProcessJob {
333 pub(crate) fn assign(child: &std::process::Child) -> Result<Self, String> {
336 use std::os::windows::io::AsRawHandle;
337 use windows_sys::Win32::System::JobObjects::{
338 AssignProcessToJobObject, CreateJobObjectW,
339 };
340
341 let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
343 if handle.is_null() {
344 return Err(format!(
345 "failed to create job object: {}",
346 std::io::Error::last_os_error()
347 ));
348 }
349 let job = Self(handle);
350 job.set_limit_flags(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
351 .map_err(|error| format!("failed to configure job object: {error}"))?;
352 let assigned = unsafe { AssignProcessToJobObject(job.0, child.as_raw_handle()) };
354 if assigned == 0 {
355 return Err(format!(
356 "failed to assign process {} to its job: {}",
357 child.id(),
358 std::io::Error::last_os_error()
359 ));
360 }
361 Ok(job)
362 }
363
364 pub(crate) fn assign_current_process() -> Result<Self, String> {
370 use windows_sys::Win32::System::JobObjects::{
371 AssignProcessToJobObject, CreateJobObjectW,
372 };
373 use windows_sys::Win32::System::Threading::GetCurrentProcess;
374
375 let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
377 if handle.is_null() {
378 return Err(format!(
379 "failed to create job object: {}",
380 std::io::Error::last_os_error()
381 ));
382 }
383 let job = Self(handle);
384 job.set_limit_flags(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
385 .map_err(|error| format!("failed to configure job object: {error}"))?;
386 let assigned = unsafe { AssignProcessToJobObject(job.0, GetCurrentProcess()) };
388 if assigned == 0 {
389 return Err(format!(
390 "failed to assign this process to its session job: {}",
391 std::io::Error::last_os_error()
392 ));
393 }
394 Ok(job)
395 }
396
397 pub(crate) fn terminate(&self) {
399 unsafe {
401 windows_sys::Win32::System::JobObjects::TerminateJobObject(self.0, 1);
402 }
403 }
404
405 pub(crate) fn release(&self) {
407 if let Err(error) = self.set_limit_flags(0) {
408 tracing::warn!("failed to release job object: {error}");
409 }
410 }
411
412 fn set_limit_flags(&self, flags: u32) -> std::io::Result<()> {
413 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
414 limits.BasicLimitInformation.LimitFlags = flags;
415 let configured = unsafe {
417 SetInformationJobObject(
418 self.0,
419 JobObjectExtendedLimitInformation,
420 std::ptr::from_ref(&limits).cast(),
421 std::mem::size_of_val(&limits) as u32,
422 )
423 };
424 if configured == 0 {
425 return Err(std::io::Error::last_os_error());
426 }
427 Ok(())
428 }
429 }
430
431 impl Drop for ProcessJob {
432 fn drop(&mut self) {
433 unsafe {
435 CloseHandle(self.0);
436 }
437 }
438 }
439
440 pub(crate) fn resume(process_id: u32) -> Result<(), String> {
442 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
443 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
444 CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
445 };
446 use windows_sys::Win32::System::Threading::{
447 OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
448 };
449
450 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
452 if snapshot == INVALID_HANDLE_VALUE {
453 return Err(format!(
454 "failed to inspect suspended process {process_id} threads: {}",
455 std::io::Error::last_os_error()
456 ));
457 }
458 let mut entry = THREADENTRY32 {
459 dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
460 ..THREADENTRY32::default()
461 };
462 let mut has_entry = unsafe { Thread32First(snapshot, &raw mut entry) } != 0;
464 let mut resumed = false;
465 let result = loop {
466 if !has_entry {
467 break if resumed {
468 Ok(())
469 } else {
470 Err(format!(
471 "suspended process {process_id} exposed no resumable thread"
472 ))
473 };
474 }
475 if entry.th32OwnerProcessID == process_id {
476 let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
478 if thread.is_null() {
479 break Err(format!(
480 "failed to open suspended process {process_id} thread: {}",
481 std::io::Error::last_os_error()
482 ));
483 }
484 let resume_result = unsafe { ResumeThread(thread) };
486 unsafe { CloseHandle(thread) };
488 if resume_result == u32::MAX {
489 break Err(format!(
490 "failed to resume process {process_id} thread: {}",
491 std::io::Error::last_os_error()
492 ));
493 }
494 resumed = true;
495 }
496 has_entry = unsafe { Thread32Next(snapshot, &raw mut entry) } != 0;
498 };
499 unsafe { CloseHandle(snapshot) };
501 result
502 }
503}
504
505#[cfg(all(test, unix))]
506mod tests {
507 use super::ProcessTree;
508 use std::os::unix::process::CommandExt;
509 use std::process::{Command, Stdio};
510 use std::time::{Duration, Instant};
511
512 fn alive(pid: i32) -> bool {
513 unsafe { libc::kill(pid, 0) == 0 }
515 }
516
517 #[test]
518 fn kill_ends_grandchildren_not_just_the_shell() {
519 let mut cmd = Command::new("sh");
520 cmd.args(["-c", "sleep 30 & echo $!; wait"])
521 .stdin(Stdio::null())
522 .stdout(Stdio::piped())
523 .process_group(0);
524 ProcessTree::prepare(&mut cmd);
525 let mut child = cmd.spawn().expect("spawn sh");
526 let tree = ProcessTree::start(&mut child).expect("start tree");
527
528 let mut line = String::new();
529 std::io::BufRead::read_line(
530 &mut std::io::BufReader::new(child.stdout.take().expect("stdout")),
531 &mut line,
532 )
533 .expect("read grandchild pid");
534 let grandchild: i32 = line.trim().parse().expect("grandchild pid");
535 assert!(
536 alive(grandchild),
537 "grandchild must be running before the kill"
538 );
539
540 tree.kill(&mut child);
541 let _ = child.wait();
542 let deadline = Instant::now() + Duration::from_secs(5);
543 while alive(grandchild) && Instant::now() < deadline {
544 std::thread::sleep(Duration::from_millis(20));
545 }
546 assert!(!alive(grandchild), "kill must reach the shell's grandchild");
547 }
548
549 #[test]
550 fn release_after_normal_exit_is_a_no_op() {
551 let mut cmd = Command::new("sh");
552 cmd.args(["-c", "exit 3"]).process_group(0);
553 ProcessTree::prepare(&mut cmd);
554 let mut child = cmd.spawn().expect("spawn sh");
555 let tree = ProcessTree::start(&mut child).expect("start tree");
556 let status = child.wait().expect("wait");
557 tree.release();
558 assert_eq!(status.code(), Some(3));
559 }
560}