Skip to main content

acyclic_native_runtime/
process_tree.rs

1#![allow(unsafe_code)]
2
3use std::io;
4use std::process::{Child, Command, ExitStatus, Output};
5
6/// One child and descendants that remain in its operating-system containment.
7///
8/// Windows uses a Job object. Unix uses a process group, which cannot contain
9/// a descendant that deliberately creates a new process group or session.
10pub struct ProcessTree {
11    child: Option<Child>,
12    guard: platform::Guard,
13}
14
15impl ProcessTree {
16    pub(crate) fn spawn(command: &mut Command) -> io::Result<Self> {
17        let (child, guard) = platform::spawn(command)?;
18        Ok(Self {
19            child: Some(child),
20            guard,
21        })
22    }
23
24    /// Polls the direct child without releasing ownership of its descendants.
25    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
26        self.child
27            .as_mut()
28            .ok_or_else(|| io::Error::other("process output was already collected"))?
29            .try_wait()
30    }
31
32    /// Collects the direct child's output while retaining the descendant guard.
33    pub fn wait_with_output(&mut self) -> io::Result<Output> {
34        self.child
35            .take()
36            .ok_or_else(|| io::Error::other("process output was already collected"))?
37            .wait_with_output()
38    }
39
40    /// Terminates every process in the tree and reaps the direct child.
41    pub fn terminate(&mut self) -> io::Result<()> {
42        self.terminate_descendants()?;
43        if let Some(child) = self.child.as_mut() {
44            child.wait()?;
45            self.child.take();
46        }
47        Ok(())
48    }
49
50    /// Signals termination to the entire tree while retaining the direct child
51    /// so its captured output can still be collected.
52    pub fn terminate_descendants(&mut self) -> io::Result<()> {
53        self.guard.terminate()
54    }
55}
56
57impl Drop for ProcessTree {
58    fn drop(&mut self) {
59        let _ = self.terminate();
60    }
61}
62
63#[cfg(unix)]
64mod platform {
65    use std::io;
66    use std::os::unix::process::CommandExt as _;
67    use std::process::{Child, Command};
68
69    pub(super) struct Guard {
70        process_group: libc::pid_t,
71        active: bool,
72    }
73
74    pub(super) fn spawn(command: &mut Command) -> io::Result<(Child, Guard)> {
75        command.process_group(0);
76        let child = command.spawn()?;
77        let process_group = libc::pid_t::try_from(child.id())
78            .map_err(|_| io::Error::other("child process id does not fit pid_t"))?;
79        Ok((
80            child,
81            Guard {
82                process_group,
83                active: true,
84            },
85        ))
86    }
87
88    impl Guard {
89        pub(super) fn terminate(&mut self) -> io::Result<()> {
90            if !self.active {
91                return Ok(());
92            }
93            // SAFETY: a negative, nonzero pid addresses exactly this owned
94            // process group; SIGKILL requires no shared memory or signal data.
95            if unsafe { libc::kill(-self.process_group, libc::SIGKILL) } == 0 {
96                self.active = false;
97                return Ok(());
98            }
99            let error = io::Error::last_os_error();
100            if error.raw_os_error() == Some(libc::ESRCH) {
101                self.active = false;
102                Ok(())
103            } else {
104                Err(error)
105            }
106        }
107    }
108}
109
110#[cfg(test)]
111#[allow(clippy::expect_used)]
112mod tests {
113    use super::ProcessTree;
114    use std::fs;
115    use std::process::{Command, Stdio};
116    use std::thread;
117    use std::time::{Duration, Instant};
118
119    const MODE: &str = "ACYCLIC_PROCESS_TREE_TEST_MODE";
120    const ROOT: &str = "ACYCLIC_PROCESS_TREE_TEST_ROOT";
121
122    #[test]
123    fn process_tree_helper() {
124        let Ok(mode) = std::env::var(MODE) else {
125            return;
126        };
127        let root = std::path::PathBuf::from(std::env::var_os(ROOT).expect("helper root"));
128        if mode == "grandchild" {
129            fs::write(root.join("grandchild-ready"), b"ready").expect("grandchild ready");
130            thread::sleep(Duration::from_millis(750));
131            fs::write(root.join("escaped"), b"descendant survived").expect("escaped marker");
132            return;
133        }
134        assert_eq!(mode, "child");
135        let mut grandchild = Command::new(std::env::current_exe().expect("test executable"));
136        grandchild
137            .args(["--exact", "process_tree::tests::process_tree_helper"])
138            .env(MODE, "grandchild")
139            .env(ROOT, &root)
140            .stdin(Stdio::null())
141            .stdout(Stdio::null())
142            .stderr(Stdio::null());
143        let mut grandchild = grandchild.spawn().expect("spawn grandchild");
144        let deadline = Instant::now() + Duration::from_secs(5);
145        while !root.join("grandchild-ready").exists() && Instant::now() < deadline {
146            thread::sleep(Duration::from_millis(10));
147        }
148        assert!(root.join("grandchild-ready").exists());
149        fs::write(root.join("tree-ready"), b"ready").expect("tree ready");
150        let _ = grandchild.wait();
151    }
152
153    #[test]
154    fn termination_contains_descendants() {
155        let temporary = tempfile::tempdir().expect("temporary process-tree directory");
156        let mut command = Command::new(std::env::current_exe().expect("test executable"));
157        command
158            .args(["--exact", "process_tree::tests::process_tree_helper"])
159            .env(MODE, "child")
160            .env(ROOT, temporary.path())
161            .stdin(Stdio::null())
162            .stdout(Stdio::null())
163            .stderr(Stdio::null());
164        let mut tree = ProcessTree::spawn(&mut command).expect("spawn process tree");
165        let deadline = Instant::now() + Duration::from_secs(5);
166        while !temporary.path().join("tree-ready").exists() && Instant::now() < deadline {
167            thread::sleep(Duration::from_millis(10));
168        }
169        assert!(temporary.path().join("tree-ready").exists());
170        tree.terminate().expect("terminate process tree");
171        thread::sleep(Duration::from_secs(1));
172        assert!(!temporary.path().join("escaped").exists());
173    }
174}
175
176#[cfg(windows)]
177mod platform {
178    use std::io;
179    use std::mem::{size_of, zeroed};
180    use std::os::windows::io::AsRawHandle as _;
181    use std::os::windows::process::CommandExt as _;
182    use std::process::{Child, Command};
183    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
184    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
185        CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
186    };
187    use windows_sys::Win32::System::JobObjects::{
188        AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
189        JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
190        SetInformationJobObject, TerminateJobObject,
191    };
192    use windows_sys::Win32::System::Threading::{
193        CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED, OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
194    };
195
196    pub(super) struct Guard {
197        job: HANDLE,
198        active: bool,
199    }
200
201    pub(super) fn spawn(command: &mut Command) -> io::Result<(Child, Guard)> {
202        let mut guard = Guard::new()?;
203        command.creation_flags(CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP);
204        let mut child = command.spawn()?;
205        // SAFETY: the Job and Child each own live handles for the duration of
206        // this call. The suspended child cannot create descendants before it
207        // has been assigned to the kill-on-close Job.
208        if unsafe { AssignProcessToJobObject(guard.job, child.as_raw_handle().cast()) } == 0 {
209            let error = io::Error::last_os_error();
210            let _ = guard.terminate();
211            let _ = child.kill();
212            let _ = child.wait();
213            return Err(error);
214        }
215        if let Err(error) = resume_process(child.id()) {
216            let _ = guard.terminate();
217            let _ = child.wait();
218            return Err(error);
219        }
220        Ok((child, guard))
221    }
222
223    impl Guard {
224        fn new() -> io::Result<Self> {
225            // SAFETY: null security attributes and name request an unnamed Job
226            // with default access controls.
227            let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
228            if job.is_null() {
229                return Err(io::Error::last_os_error());
230            }
231            // SAFETY: the structure is plain Win32 data and zero is its
232            // documented initial state before setting the requested limit.
233            let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
234            limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
235            // SAFETY: `limits` is initialized and its exact size is provided.
236            let configured = unsafe {
237                SetInformationJobObject(
238                    job,
239                    JobObjectExtendedLimitInformation,
240                    (&raw const limits).cast(),
241                    u32::try_from(size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
242                        .map_err(|_| io::Error::other("Job limit structure is too large"))?,
243                )
244            };
245            if configured == 0 {
246                let error = io::Error::last_os_error();
247                // SAFETY: `job` is an owned live handle.
248                unsafe { CloseHandle(job) };
249                return Err(error);
250            }
251            Ok(Self { job, active: true })
252        }
253
254        pub(super) fn terminate(&mut self) -> io::Result<()> {
255            if !self.active {
256                return Ok(());
257            }
258            // SAFETY: `job` remains owned until Drop closes it.
259            if unsafe { TerminateJobObject(self.job, 1) } == 0 {
260                Err(io::Error::last_os_error())
261            } else {
262                self.active = false;
263                Ok(())
264            }
265        }
266    }
267
268    impl Drop for Guard {
269        fn drop(&mut self) {
270            let _ = self.terminate();
271            // SAFETY: `job` is owned by this guard and closed exactly once.
272            unsafe { CloseHandle(self.job) };
273        }
274    }
275
276    fn resume_process(process_id: u32) -> io::Result<()> {
277        // SAFETY: flags request a system-owned snapshot handle.
278        let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
279        if snapshot == INVALID_HANDLE_VALUE {
280            return Err(io::Error::last_os_error());
281        }
282        // SAFETY: zero is the documented initialization; dwSize is set below.
283        let mut entry: THREADENTRY32 = unsafe { zeroed() };
284        entry.dwSize = u32::try_from(size_of::<THREADENTRY32>())
285            .map_err(|_| io::Error::other("thread entry structure is too large"))?;
286        // SAFETY: snapshot and entry are live for the iteration.
287        let mut found = unsafe { Thread32First(snapshot, &raw mut entry) } != 0;
288        let mut result = Err(io::Error::new(
289            io::ErrorKind::NotFound,
290            "suspended child thread was not found",
291        ));
292        while found {
293            if entry.th32OwnerProcessID == process_id {
294                // SAFETY: the thread id came from the live system snapshot.
295                let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
296                if thread.is_null() {
297                    result = Err(io::Error::last_os_error());
298                } else {
299                    // SAFETY: `thread` is a live handle with resume access.
300                    result = if unsafe { ResumeThread(thread) } == u32::MAX {
301                        Err(io::Error::last_os_error())
302                    } else {
303                        Ok(())
304                    };
305                    // SAFETY: this function owns the thread handle.
306                    unsafe { CloseHandle(thread) };
307                }
308                break;
309            }
310            // SAFETY: snapshot and entry remain live for the next iteration.
311            found = unsafe { Thread32Next(snapshot, &raw mut entry) } != 0;
312        }
313        // SAFETY: this function owns the snapshot handle.
314        unsafe { CloseHandle(snapshot) };
315        result
316    }
317}