Skip to main content

lean_ctx/shell/
process_tree.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Whole-tree ownership of a spawned command (#1920).
3//!
4//! A timeout or cancel has to end every process the command started, not just
5//! the shell leader. On Unix callers already isolate the child in its own
6//! process group, and `killpg` reaches every descendant. Windows has no such
7//! group: `Child::kill` ends the shell alone, and a `python -` it started keeps
8//! running detached — spinning a core for hours when its script loops.
9//!
10//! A job object is the Windows equivalent. The child is spawned suspended,
11//! assigned to a private job and only then resumed, so nothing it starts can
12//! escape. `TerminateJobObject` ends the whole tree at once, and
13//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` ends it when lean-ctx itself dies before
14//! it could clean up.
15
16use std::process::{Child, Command};
17
18/// The process tree of one spawned command. Create it with `ProcessTree::start`
19/// right after spawning a command prepared by [`ProcessTree::prepare`].
20pub struct ProcessTree {
21    #[cfg(windows)]
22    job: Option<job::ProcessJob>,
23}
24
25impl ProcessTree {
26    /// Configure `cmd` so its tree can be contained. On Windows this spawns the
27    /// child suspended — `ProcessTree::start` must follow every successful
28    /// spawn. It sets the creation flags, so callers must not set their own.
29    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    /// Take ownership of the tree of a child spawned from a prepared command
40    /// and let it run.
41    ///
42    /// A job that cannot be created or assigned is not fatal: the command still
43    /// runs, and [`ProcessTree::kill`] falls back to `taskkill /T`. A child that
44    /// cannot be resumed is killed and reported, never left suspended.
45    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    /// Start `command` as one contained call (B7): on Windows its tree goes
73    /// into a private kill-on-close job before its first instruction runs
74    /// (spawned suspended, assigned, resumed). The caller holds the returned
75    /// tree for the call and drops it afterwards, which ends whatever the
76    /// call left running; the tree ends as well should this process die
77    /// first. A job that cannot be created leaves the previous behaviour
78    /// (`taskkill /T` on a limit). A child that cannot be resumed was killed
79    /// without having run, and the error says so. (Unix calls start behind
80    /// their guard instead, `shell::containment::start_behind_guard`.)
81    #[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    /// Kill the command and every descendant it started. Killing only the
90    /// leader would leave grandchildren alive and the caller's pipe readers
91    /// unable to reach EOF (#995).
92    ///
93    /// On Unix the child must lead its own process group (`setsid` or
94    /// `process_group(0)`), which makes its pid the group id.
95    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                // SAFETY: killpg is a plain syscall; a stale group yields ESRCH.
100                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    /// Hand the tree back after the command exited on its own.
119    ///
120    /// A process it deliberately left running (`start /b server`) survives, as
121    /// it does on Unix, where an exited leader leaves its group alone. Dropping
122    /// a `ProcessTree` without this — lean-ctx unwinding or dying mid-command —
123    /// ends the whole tree instead.
124    pub(crate) fn release(self) {
125        #[cfg(windows)]
126        if let Some(job) = &self.job {
127            job.release();
128        }
129    }
130}
131
132/// The output of a started shell, wherever its pipes were made.
133type 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
139/// A started MCP shell: the process, its output, and what the caller has to
140/// show in front of the result, if anything.
141pub struct StartedShell {
142    pub child: ShellChild,
143    pub stdout: ShellPipe,
144    pub stderr: ShellPipe,
145    /// A fixed, content-free line (see `shell::containment`).
146    pub notice: Option<&'static str>,
147}
148
149/// The shell of one MCP shell call, whoever its parent is.
150///
151/// On Unix the parent is this process's spawn helper (`shell::containment`):
152/// it holds the shell's session and ends it should this process die. Where
153/// there is no helper — Windows, a host that is not `lean-ctx`, a helper that
154/// could not be started — this process is the parent, as it always was.
155pub 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    /// Starts the shell that `cmd` describes, in a session of its own on
166    /// Unix, with its output piped. `cmd` must have been through
167    /// [`ProcessTree::prepare`] and must not set its own standard streams.
168    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    /// The shell's process id; on Unix also the id of the group it leads.
209    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    /// The exit status, if the shell has exited. Reaps a direct child.
218    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    /// Ends the shell and everything it started (`ProcessTree::kill`). The
227    /// group is signalled by the shell's parent, while the shell is unreaped.
228    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    /// Waits for a shell that was just killed to be reaped.
237    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    /// Hands the tree back after the command exited on its own
248    /// (`ProcessTree::release`): what it left running stays.
249    ///
250    /// `Some`: the fixed notice for a release that could not be handed to
251    /// the shell's helper — whether what the command left running is still
252    /// running is then not known. The caller shows it with the result.
253    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    /// The fixed notice for a shell whose helper went away — before it said
267    /// whether the command was started, or before the shell's exit was
268    /// known. The call's outcome is then not known in full. Never the case
269    /// for a direct child.
270    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/// Session containment on Windows (Q13/B7), the counterpart of the Unix
280/// session starter (`shell::containment`).
281///
282/// An SDK asks for it with `LEAN_CTX_SESSION_CONTAINMENT=1` on an `engine …`
283/// start. There is no starter process here: the Engine puts itself into a
284/// kill-on-close job before it does anything else. From then on everything it
285/// starts belongs to that job, and all of it ends when the Engine process
286/// ends — on its own, because the SDK ended it, or because its input closed
287/// after the SDK went away. Only then is the acknowledgement written, the
288/// same first line as on Unix, so an SDK reports containment exactly when it
289/// exists. A job that cannot be created or joined (a host job that forbids
290/// nesting) writes nothing: the Engine runs as before and the SDK reports
291/// `unavailable`.
292///
293/// The request is taken out of the environment first, as the starter does, so
294/// no process the Engine starts sees or repeats it.
295#[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    // SAFETY: called first thing in `run`, before any other thread exists.
309    unsafe { std::env::remove_var(SESSION_ENV) };
310    let Ok(session) = job::ProcessJob::assign_current_process() else {
311        return;
312    };
313    // The handle must live exactly as long as the process: it is never
314    // dropped, and the system closes it when the process ends.
315    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    /// A private job object whose processes die when its last handle closes.
329    pub(crate) struct ProcessJob(HANDLE);
330
331    impl ProcessJob {
332        /// Create a kill-on-close job and assign `child` to it. The child should
333        /// still be suspended so nothing it starts can escape the job.
334        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            // SAFETY: null attributes/name create a private job owned by Self.
341            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            // SAFETY: Child owns a live process handle for the spawned process.
352            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        /// Create a kill-on-close job and put THIS process into it. Every
364        /// process it starts from now on is a member (a per-call job of
365        /// [`ProcessJob::assign`] nests inside), and all of them end when the
366        /// last handle closes — which is when this process ends, since the
367        /// handle is private and not inheritable.
368        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            // SAFETY: null attributes/name create a private job owned by Self.
375            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            // SAFETY: the pseudo-handle of the current process is always valid.
386            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        /// End every process in the job.
397        pub(crate) fn terminate(&self) {
398            // SAFETY: self.0 is a live job handle owned by this instance.
399            unsafe {
400                windows_sys::Win32::System::JobObjects::TerminateJobObject(self.0, 1);
401            }
402        }
403
404        /// Stop killing the job's processes when the handle closes.
405        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            // SAFETY: limits has the exact structure and size this info class requires.
415            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            // SAFETY: this instance exclusively owns the CreateJobObjectW handle.
433            unsafe {
434                CloseHandle(self.0);
435            }
436        }
437    }
438
439    /// Resume every thread of a process spawned with `CREATE_SUSPENDED`.
440    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        // SAFETY: snapshot handle is closed on every return path below.
450        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        // SAFETY: snapshot and entry are valid for ToolHelp thread enumeration.
462        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                // SAFETY: entry names a thread owned by the suspended child process.
476                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                // SAFETY: thread is a live handle with THREAD_SUSPEND_RESUME access.
484                let resume_result = unsafe { ResumeThread(thread) };
485                // SAFETY: this scope owns the OpenThread handle.
486                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            // SAFETY: snapshot and entry remain valid until CloseHandle below.
496            has_entry = unsafe { Thread32Next(snapshot, &raw mut entry) } != 0;
497        };
498        // SAFETY: this scope owns the ToolHelp snapshot handle.
499        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        // SAFETY: signal 0 only probes whether the pid exists.
513        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}