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            let pgid = child.id() as libc::pid_t;
99            if pgid > 0 {
100                // SAFETY: killpg is a plain syscall; a stale group yields ESRCH.
101                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    /// Hand the tree back after the command exited on its own.
120    ///
121    /// A process it deliberately left running (`start /b server`) survives, as
122    /// it does on Unix, where an exited leader leaves its group alone. Dropping
123    /// a `ProcessTree` without this — lean-ctx unwinding or dying mid-command —
124    /// ends the whole tree instead.
125    pub(crate) fn release(self) {
126        #[cfg(windows)]
127        if let Some(job) = &self.job {
128            job.release();
129        }
130    }
131}
132
133/// The output of a started shell, wherever its pipes were made.
134type 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
140/// A started MCP shell: the process, its output, and what the caller has to
141/// show in front of the result, if anything.
142pub struct StartedShell {
143    pub child: ShellChild,
144    pub stdout: ShellPipe,
145    pub stderr: ShellPipe,
146    /// A fixed, content-free line (see `shell::containment`).
147    pub notice: Option<&'static str>,
148}
149
150/// The shell of one MCP shell call, whoever its parent is.
151///
152/// On Unix the parent is this process's spawn helper (`shell::containment`):
153/// it holds the shell's session and ends it should this process die. Where
154/// there is no helper — Windows, a host that is not `lean-ctx`, a helper that
155/// could not be started — this process is the parent, as it always was.
156pub 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    /// Starts the shell that `cmd` describes, in a session of its own on
167    /// Unix, with its output piped. `cmd` must have been through
168    /// [`ProcessTree::prepare`] and must not set its own standard streams.
169    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    /// The shell's process id; on Unix also the id of the group it leads.
210    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    /// The exit status, if the shell has exited. Reaps a direct child.
219    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    /// Ends the shell and everything it started (`ProcessTree::kill`). The
228    /// group is signalled by the shell's parent, while the shell is unreaped.
229    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    /// Waits for a shell that was just killed to be reaped.
238    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    /// Hands the tree back after the command exited on its own
249    /// (`ProcessTree::release`): what it left running stays.
250    ///
251    /// `Some`: the fixed notice for a release that could not be handed to
252    /// the shell's helper — whether what the command left running is still
253    /// running is then not known. The caller shows it with the result.
254    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    /// The fixed notice for a shell whose helper went away — before it said
268    /// whether the command was started, or before the shell's exit was
269    /// known. The call's outcome is then not known in full. Never the case
270    /// for a direct child.
271    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/// Session containment on Windows (Q13/B7), the counterpart of the Unix
281/// session starter (`shell::containment`).
282///
283/// An SDK asks for it with `LEAN_CTX_SESSION_CONTAINMENT=1` on an `engine …`
284/// start. There is no starter process here: the Engine puts itself into a
285/// kill-on-close job before it does anything else. From then on everything it
286/// starts belongs to that job, and all of it ends when the Engine process
287/// ends — on its own, because the SDK ended it, or because its input closed
288/// after the SDK went away. Only then is the acknowledgement written, the
289/// same first line as on Unix, so an SDK reports containment exactly when it
290/// exists. A job that cannot be created or joined (a host job that forbids
291/// nesting) writes nothing: the Engine runs as before and the SDK reports
292/// `unavailable`.
293///
294/// The request is taken out of the environment first, as the starter does, so
295/// no process the Engine starts sees or repeats it.
296#[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    // SAFETY: called first thing in `run`, before any other thread exists.
310    unsafe { std::env::remove_var(SESSION_ENV) };
311    let Ok(session) = job::ProcessJob::assign_current_process() else {
312        return;
313    };
314    // The handle must live exactly as long as the process: it is never
315    // dropped, and the system closes it when the process ends.
316    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    /// A private job object whose processes die when its last handle closes.
330    pub(crate) struct ProcessJob(HANDLE);
331
332    impl ProcessJob {
333        /// Create a kill-on-close job and assign `child` to it. The child should
334        /// still be suspended so nothing it starts can escape the job.
335        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            // SAFETY: null attributes/name create a private job owned by Self.
342            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            // SAFETY: Child owns a live process handle for the spawned process.
353            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        /// Create a kill-on-close job and put THIS process into it. Every
365        /// process it starts from now on is a member (a per-call job of
366        /// [`ProcessJob::assign`] nests inside), and all of them end when the
367        /// last handle closes — which is when this process ends, since the
368        /// handle is private and not inheritable.
369        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            // SAFETY: null attributes/name create a private job owned by Self.
376            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            // SAFETY: the pseudo-handle of the current process is always valid.
387            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        /// End every process in the job.
398        pub(crate) fn terminate(&self) {
399            // SAFETY: self.0 is a live job handle owned by this instance.
400            unsafe {
401                windows_sys::Win32::System::JobObjects::TerminateJobObject(self.0, 1);
402            }
403        }
404
405        /// Stop killing the job's processes when the handle closes.
406        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            // SAFETY: limits has the exact structure and size this info class requires.
416            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            // SAFETY: this instance exclusively owns the CreateJobObjectW handle.
434            unsafe {
435                CloseHandle(self.0);
436            }
437        }
438    }
439
440    /// Resume every thread of a process spawned with `CREATE_SUSPENDED`.
441    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        // SAFETY: snapshot handle is closed on every return path below.
451        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        // SAFETY: snapshot and entry are valid for ToolHelp thread enumeration.
463        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                // SAFETY: entry names a thread owned by the suspended child process.
477                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                // SAFETY: thread is a live handle with THREAD_SUSPEND_RESUME access.
485                let resume_result = unsafe { ResumeThread(thread) };
486                // SAFETY: this scope owns the OpenThread handle.
487                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            // SAFETY: snapshot and entry remain valid until CloseHandle below.
497            has_entry = unsafe { Thread32Next(snapshot, &raw mut entry) } != 0;
498        };
499        // SAFETY: this scope owns the ToolHelp snapshot handle.
500        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        // SAFETY: signal 0 only probes whether the pid exists.
514        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}