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