Skip to main content

fallow_process/
process_tree.rs

1//! Shared process-tree ownership and bounded child cleanup.
2
3use std::io;
4use std::process::ExitStatus;
5use std::time::{Duration, Instant};
6
7const CLEANUP_GRACE: Duration = Duration::from_secs(1);
8const REAP_RETRY_GRACE: Duration = Duration::from_millis(100);
9const CLEANUP_POLL_INTERVAL: Duration = Duration::from_millis(10);
10const MANAGED_PROCESS_TREE_ENV: &str = "FALLOW_MANAGED_PROCESS_TREE";
11
12/// Configure a Tokio command so its descendants can be terminated as one tree.
13#[cfg(feature = "tokio")]
14pub fn configure_tokio_command(command: &mut tokio::process::Command) {
15    configure_std_command(command.as_std_mut());
16}
17
18/// Configure a standard-library command so its descendants can be terminated as one tree.
19pub fn configure_std_command(command: &mut std::process::Command) {
20    command.env(MANAGED_PROCESS_TREE_ENV, "1");
21
22    #[cfg(unix)]
23    {
24        use std::os::unix::process::CommandExt;
25
26        command.process_group(0);
27    }
28
29    #[cfg(windows)]
30    {
31        use std::os::windows::process::CommandExt;
32
33        use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;
34
35        command.creation_flags(CREATE_SUSPENDED);
36    }
37
38    #[cfg(not(any(unix, windows)))]
39    let _ = command;
40}
41
42/// Whether this process already belongs to a tree owned by a Fallow parent.
43///
44/// Nested subprocesses must inherit that tree. Creating a second process group
45/// on Unix would let the nested group survive termination of the outer group.
46/// On Windows, normal child creation keeps the process in the inherited Job
47/// Object.
48pub fn inherits_managed_process_tree() -> bool {
49    std::env::var_os(MANAGED_PROCESS_TREE_ENV).is_some_and(|value| value == "1")
50}
51
52#[cfg(windows)]
53struct WindowsHandle(isize);
54
55#[cfg(windows)]
56impl WindowsHandle {
57    fn raw(&self) -> windows_sys::Win32::Foundation::HANDLE {
58        self.0 as _
59    }
60}
61
62#[cfg(windows)]
63#[expect(unsafe_code, reason = "owned Windows handles require CloseHandle")]
64impl Drop for WindowsHandle {
65    fn drop(&mut self) {
66        use windows_sys::Win32::Foundation::CloseHandle;
67
68        // SAFETY: The handle is owned by this value and Drop runs once.
69        unsafe { CloseHandle(self.raw()) };
70    }
71}
72
73#[cfg(windows)]
74struct WindowsJobGuard {
75    job: Option<WindowsHandle>,
76}
77
78#[cfg(windows)]
79impl WindowsJobGuard {
80    fn new(job: WindowsHandle) -> Self {
81        Self { job: Some(job) }
82    }
83
84    fn raw(&self) -> io::Result<windows_sys::Win32::Foundation::HANDLE> {
85        self.job
86            .as_ref()
87            .map(WindowsHandle::raw)
88            .ok_or_else(|| io::Error::other("Windows Job Object guard is disarmed"))
89    }
90
91    fn disarm(mut self) -> io::Result<WindowsHandle> {
92        self.job
93            .take()
94            .ok_or_else(|| io::Error::other("Windows Job Object guard is already disarmed"))
95    }
96}
97
98#[cfg(windows)]
99#[expect(
100    unsafe_code,
101    reason = "armed Windows Job Object cleanup requires TerminateJobObject"
102)]
103impl Drop for WindowsJobGuard {
104    fn drop(&mut self) {
105        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
106
107        let Some(job) = self.job.as_ref() else {
108            return;
109        };
110        // SAFETY: The guard owns the live Job Object handle. Its WindowsHandle
111        // field closes the handle immediately after this Drop implementation.
112        unsafe { TerminateJobObject(job.raw(), 1) };
113    }
114}
115
116/// Platform-specific ownership needed to terminate a spawned process tree.
117pub struct ProcessTree {
118    #[cfg(unix)]
119    process_group_id: i32,
120    #[cfg(unix)]
121    leader_exit_observed: std::sync::atomic::AtomicBool,
122    #[cfg(windows)]
123    job: WindowsHandle,
124}
125
126impl ProcessTree {
127    /// Bind a freshly spawned Tokio child to its preconfigured process tree.
128    #[cfg(all(feature = "tokio", unix))]
129    pub fn for_tokio_child(child: &tokio::process::Child) -> io::Result<Self> {
130        let pid = child
131            .id()
132            .ok_or_else(|| io::Error::other("fallow subprocess exited before setup"))?;
133        Self::for_pid(pid)
134    }
135
136    /// Bind a freshly spawned Tokio child to its preconfigured process tree.
137    #[cfg(all(feature = "tokio", windows))]
138    pub fn for_tokio_child(child: &tokio::process::Child) -> io::Result<Self> {
139        let pid = child
140            .id()
141            .ok_or_else(|| io::Error::other("fallow subprocess exited before setup"))?;
142        let handle = child
143            .raw_handle()
144            .ok_or_else(|| io::Error::other("fallow subprocess exited before setup"))?;
145        Self::for_windows_handle(handle, pid)
146    }
147
148    /// Bind a freshly spawned Tokio child on platforms without tree support.
149    #[cfg(all(feature = "tokio", not(any(unix, windows))))]
150    pub fn for_tokio_child(_child: &tokio::process::Child) -> io::Result<Self> {
151        Ok(Self {})
152    }
153
154    /// Bind a freshly spawned standard-library child to its preconfigured
155    /// process tree.
156    #[cfg(unix)]
157    pub fn for_std_child(child: &std::process::Child) -> io::Result<Self> {
158        Self::for_pid(child.id())
159    }
160
161    /// Bind a freshly spawned standard-library child to its preconfigured
162    /// process tree.
163    #[cfg(windows)]
164    pub fn for_std_child(child: &std::process::Child) -> io::Result<Self> {
165        use std::os::windows::io::AsRawHandle;
166
167        Self::for_windows_handle(child.as_raw_handle(), child.id())
168    }
169
170    /// Bind a freshly spawned standard-library child on platforms without
171    /// process-tree support.
172    #[cfg(not(any(unix, windows)))]
173    pub fn for_std_child(_child: &std::process::Child) -> io::Result<Self> {
174        Ok(Self {})
175    }
176
177    #[cfg(unix)]
178    pub(crate) fn for_pid(pid: u32) -> io::Result<Self> {
179        let process_group_id = i32::try_from(pid)
180            .map_err(|_| io::Error::other(format!("invalid fallow subprocess PID {pid}")))?;
181        Ok(Self {
182            process_group_id,
183            leader_exit_observed: std::sync::atomic::AtomicBool::new(false),
184        })
185    }
186
187    #[cfg(windows)]
188    #[expect(unsafe_code, reason = "Windows Job Objects require Win32 FFI calls")]
189    fn for_windows_handle(process: std::os::windows::io::RawHandle, pid: u32) -> io::Result<Self> {
190        use std::mem;
191        use std::ptr;
192
193        use windows_sys::Win32::System::JobObjects::{
194            AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
195            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
196            SetInformationJobObject,
197        };
198
199        // SAFETY: Both pointers are null by contract, creating an unnamed job
200        // with default security attributes.
201        let job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
202        if job.is_null() {
203            return Err(io::Error::last_os_error());
204        }
205        let job = WindowsJobGuard::new(WindowsHandle(job as isize));
206        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
207        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
208
209        // SAFETY: The buffer has the exact information-class layout and remains
210        // alive for the duration of this call.
211        if unsafe {
212            SetInformationJobObject(
213                job.raw()?,
214                JobObjectExtendedLimitInformation,
215                (&raw const limits).cast(),
216                mem::size_of_val(&limits) as u32,
217            )
218        } == 0
219        {
220            return Err(io::Error::last_os_error());
221        }
222
223        // SAFETY: `job` is a live handle from CreateJobObjectW and `process` is
224        // borrowed from the freshly spawned child for the duration of this call.
225        if unsafe { AssignProcessToJobObject(job.raw()?, process.cast()) } == 0 {
226            return Err(io::Error::last_os_error());
227        }
228
229        resume_suspended_process(pid)?;
230        Ok(Self { job: job.disarm()? })
231    }
232
233    /// Terminate the complete owned process tree.
234    #[cfg(unix)]
235    #[expect(
236        unsafe_code,
237        reason = "POSIX process-group termination requires libc::kill"
238    )]
239    pub fn terminate(&self) -> io::Result<()> {
240        // SAFETY: A negative PID targets the dedicated process group created by
241        // `process_group(0)`. SIGKILL has no borrowed-memory requirements.
242        if unsafe { libc::kill(-self.process_group_id, libc::SIGKILL) } == 0 {
243            return Ok(());
244        }
245
246        let error = io::Error::last_os_error();
247        if error.raw_os_error() == Some(libc::ESRCH) {
248            return Ok(());
249        }
250        #[cfg(target_vendor = "apple")]
251        // macOS returns EPERM when the reserved process group contains only
252        // the observed zombie leader, so there are no live members to signal.
253        if error.raw_os_error() == Some(libc::EPERM)
254            && self
255                .leader_exit_observed
256                .load(std::sync::atomic::Ordering::Relaxed)
257        {
258            return Ok(());
259        }
260        Err(error)
261    }
262
263    /// Wait until the child leader exits without reaping its process-group ID.
264    #[cfg(all(feature = "tokio", unix))]
265    pub async fn wait_for_exit_without_reaping(&self) -> io::Result<()> {
266        loop {
267            if self.has_exited_without_reaping()? {
268                return Ok(());
269            }
270            tokio::time::sleep(CLEANUP_POLL_INTERVAL).await;
271        }
272    }
273
274    /// Check whether the child leader exited while preserving its process-group
275    /// identity for safe descendant cleanup.
276    #[cfg(unix)]
277    #[expect(
278        unsafe_code,
279        reason = "non-reaping POSIX child observation requires waitid"
280    )]
281    pub fn has_exited_without_reaping(&self) -> io::Result<bool> {
282        let mut info = std::mem::MaybeUninit::<libc::siginfo_t>::zeroed();
283        // SAFETY: `info` points to writable storage for a siginfo_t. WNOWAIT
284        // observes the dedicated child without releasing its PID or PGID.
285        let result = unsafe {
286            libc::waitid(
287                libc::P_PID,
288                self.process_group_id as libc::id_t,
289                info.as_mut_ptr(),
290                libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
291            )
292        };
293        if result != 0 {
294            return Err(io::Error::last_os_error());
295        }
296
297        // SAFETY: waitid initialized the siginfo_t on success. A zero si_pid
298        // means WNOHANG observed no state change yet.
299        let exited = unsafe { info.assume_init().si_pid() } != 0;
300        if exited {
301            self.leader_exit_observed
302                .store(true, std::sync::atomic::Ordering::Relaxed);
303        }
304        Ok(exited)
305    }
306
307    /// Terminate the complete owned process tree.
308    #[cfg(windows)]
309    #[expect(
310        unsafe_code,
311        reason = "Windows Job Object termination requires a Win32 FFI call"
312    )]
313    pub fn terminate(&self) -> io::Result<()> {
314        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
315
316        // SAFETY: The handle remains owned by this ProcessTree until Drop.
317        if unsafe { TerminateJobObject(self.job.raw(), 1) } != 0 {
318            return Ok(());
319        }
320        Err(io::Error::last_os_error())
321    }
322
323    /// Report that process-tree termination is unavailable.
324    #[cfg(not(any(unix, windows)))]
325    pub fn terminate(&self) -> io::Result<()> {
326        Err(io::Error::new(
327            io::ErrorKind::Unsupported,
328            "process-tree termination is unsupported on this platform",
329        ))
330    }
331
332    #[cfg(unix)]
333    #[expect(
334        unsafe_code,
335        reason = "POSIX process-group liveness checks require libc::kill"
336    )]
337    pub(crate) fn is_alive(&self) -> bool {
338        // SAFETY: Signal 0 checks existence without delivering a signal.
339        unsafe { libc::kill(-self.process_group_id, 0) == 0 }
340    }
341
342    #[cfg(windows)]
343    #[expect(
344        unsafe_code,
345        reason = "Windows Job Object liveness requires QueryInformationJobObject"
346    )]
347    pub(crate) fn is_alive(&self) -> bool {
348        use std::mem;
349        use std::ptr;
350
351        use windows_sys::Win32::System::JobObjects::{
352            JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JobObjectBasicAccountingInformation,
353            QueryInformationJobObject,
354        };
355
356        let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default();
357        // SAFETY: The output buffer has the requested information-class layout
358        // and remains writable for the call.
359        unsafe {
360            QueryInformationJobObject(
361                self.job.raw(),
362                JobObjectBasicAccountingInformation,
363                (&raw mut accounting).cast(),
364                mem::size_of_val(&accounting) as u32,
365                ptr::null_mut(),
366            ) != 0
367                && accounting.ActiveProcesses > 0
368        }
369    }
370
371    #[cfg(not(any(unix, windows)))]
372    pub(crate) fn is_alive(&self) -> bool {
373        false
374    }
375}
376
377/// Outcome of a bounded child cleanup attempt.
378pub struct ChildCleanup {
379    /// Reaped child status, when it became available within the cleanup budget.
380    pub status: Option<ExitStatus>,
381    /// Cleanup diagnostics that should be attached to the caller's error.
382    pub errors: Vec<String>,
383}
384
385#[cfg(feature = "tokio")]
386/// Terminate and reap a Tokio child with bounded retries.
387pub async fn cleanup_tokio_child(
388    process_tree: Option<&ProcessTree>,
389    child: &mut tokio::process::Child,
390) -> ChildCleanup {
391    let mut errors = Vec::new();
392    if !request_tree_termination(process_tree, &mut errors)
393        && let Err(error) = child.start_kill()
394    {
395        errors.push(format!("failed to kill direct subprocess: {error}"));
396    }
397
398    let status = match tokio::time::timeout(CLEANUP_GRACE, child.wait()).await {
399        Ok(Ok(status)) => {
400            return ChildCleanup {
401                status: Some(status),
402                errors,
403            };
404        }
405        Ok(Err(error)) => {
406            errors.push(format!("failed to reap direct subprocess: {error}"));
407            return ChildCleanup {
408                status: None,
409                errors,
410            };
411        }
412        Err(_) => {
413            errors.push(format!(
414                "direct subprocess did not exit within {}ms cleanup grace",
415                CLEANUP_GRACE.as_millis()
416            ));
417            None
418        }
419    };
420
421    if let Err(error) = child.start_kill() {
422        errors.push(format!("failed to retry direct subprocess kill: {error}"));
423    }
424    let status = match tokio::time::timeout(REAP_RETRY_GRACE, child.wait()).await {
425        Ok(Ok(status)) => Some(status),
426        Ok(Err(error)) => {
427            errors.push(format!(
428                "failed to reap direct subprocess after retry: {error}"
429            ));
430            status
431        }
432        Err(_) => {
433            errors.push(format!(
434                "direct subprocess still did not exit after {}ms kill retry",
435                REAP_RETRY_GRACE.as_millis()
436            ));
437            status
438        }
439    };
440    ChildCleanup { status, errors }
441}
442
443/// Terminate and reap a standard-library child with bounded retries.
444pub fn cleanup_std_child(
445    process_tree: Option<&ProcessTree>,
446    child: &mut std::process::Child,
447) -> ChildCleanup {
448    let mut errors = Vec::new();
449    if !request_tree_termination(process_tree, &mut errors)
450        && let Err(error) = child.kill()
451    {
452        errors.push(format!("failed to kill direct subprocess: {error}"));
453    }
454
455    let status = match poll_std_child(child, CLEANUP_GRACE) {
456        Ok(Some(status)) => {
457            return ChildCleanup {
458                status: Some(status),
459                errors,
460            };
461        }
462        Ok(None) => {
463            errors.push(format!(
464                "direct subprocess did not exit within {}ms cleanup grace",
465                CLEANUP_GRACE.as_millis()
466            ));
467            None
468        }
469        Err(error) => {
470            errors.push(format!("failed to reap direct subprocess: {error}"));
471            return ChildCleanup {
472                status: None,
473                errors,
474            };
475        }
476    };
477
478    if let Err(error) = child.kill() {
479        errors.push(format!("failed to retry direct subprocess kill: {error}"));
480    }
481    let status = match poll_std_child(child, REAP_RETRY_GRACE) {
482        Ok(Some(status)) => Some(status),
483        Ok(None) => {
484            errors.push(format!(
485                "direct subprocess still did not exit after {}ms kill retry",
486                REAP_RETRY_GRACE.as_millis()
487            ));
488            status
489        }
490        Err(error) => {
491            errors.push(format!(
492                "failed to reap direct subprocess after retry: {error}"
493            ));
494            status
495        }
496    };
497    ChildCleanup { status, errors }
498}
499
500fn request_tree_termination(process_tree: Option<&ProcessTree>, errors: &mut Vec<String>) -> bool {
501    let Some(process_tree) = process_tree else {
502        return false;
503    };
504    match process_tree.terminate() {
505        Ok(()) => true,
506        Err(error) => {
507            errors.push(format!("failed to terminate subprocess tree: {error}"));
508            false
509        }
510    }
511}
512
513fn poll_std_child(
514    child: &mut std::process::Child,
515    grace: Duration,
516) -> io::Result<Option<ExitStatus>> {
517    let deadline = Instant::now() + grace;
518    loop {
519        if let Some(status) = child.try_wait()? {
520            return Ok(Some(status));
521        }
522
523        let remaining = deadline.saturating_duration_since(Instant::now());
524        if remaining.is_zero() {
525            return Ok(None);
526        }
527        std::thread::sleep(CLEANUP_POLL_INTERVAL.min(remaining));
528    }
529}
530
531#[cfg(all(test, feature = "tokio", unix))]
532#[expect(
533    clippy::expect_used,
534    reason = "test setup failures should fail at the exact setup operation"
535)]
536mod tests {
537    use super::*;
538
539    #[tokio::test]
540    #[expect(
541        unsafe_code,
542        reason = "the regression test verifies that the observed PID remains reserved"
543    )]
544    async fn non_reaping_exit_observation_reserves_process_group_identity() {
545        let mut command = tokio::process::Command::new("/bin/sh");
546        command.args(["-c", "exit 0"]);
547        configure_tokio_command(&mut command);
548        let mut child = command.spawn().expect("test subprocess");
549        let pid = child.id().expect("test subprocess PID");
550        let process_tree = ProcessTree::for_tokio_child(&child).expect("test process tree");
551
552        tokio::time::timeout(
553            Duration::from_secs(1),
554            process_tree.wait_for_exit_without_reaping(),
555        )
556        .await
557        .expect("test subprocess exit")
558        .expect("non-reaping exit observation");
559        // SAFETY: Signal zero only checks whether the captured PID still exists.
560        let leader_is_reserved = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
561        let cleanup = cleanup_tokio_child(Some(&process_tree), &mut child).await;
562
563        assert!(leader_is_reserved, "subprocess leader was reaped too early");
564        assert!(cleanup.errors.is_empty(), "{:?}", cleanup.errors);
565        assert!(cleanup.status.is_some_and(|status| status.success()));
566    }
567}
568
569#[cfg(windows)]
570fn resume_suspended_process(pid: u32) -> io::Result<()> {
571    const THREAD_DISCOVERY_ATTEMPTS: usize = 20;
572    const THREAD_DISCOVERY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
573
574    for _ in 0..THREAD_DISCOVERY_ATTEMPTS {
575        match find_process_thread(pid) {
576            Ok(thread) => return resume_thread(&thread),
577            Err(error) if error.kind() == io::ErrorKind::NotFound => {
578                std::thread::sleep(THREAD_DISCOVERY_DELAY);
579            }
580            Err(error) => return Err(error),
581        }
582    }
583
584    Err(io::Error::new(
585        io::ErrorKind::NotFound,
586        format!("could not find suspended primary thread for fallow subprocess {pid}"),
587    ))
588}
589
590#[cfg(windows)]
591#[expect(
592    unsafe_code,
593    reason = "thread discovery requires Windows ToolHelp FFI calls"
594)]
595fn find_process_thread(pid: u32) -> io::Result<WindowsHandle> {
596    use std::mem;
597
598    use windows_sys::Win32::Foundation::{ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE};
599    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
600        CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
601    };
602    use windows_sys::Win32::System::Threading::{OpenThread, THREAD_SUSPEND_RESUME};
603
604    // SAFETY: The flags and process ID follow the ToolHelp API contract.
605    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
606    if snapshot == INVALID_HANDLE_VALUE {
607        return Err(io::Error::last_os_error());
608    }
609    let snapshot = WindowsHandle(snapshot as isize);
610    let mut entry = THREADENTRY32 {
611        dwSize: mem::size_of::<THREADENTRY32>() as u32,
612        ..THREADENTRY32::default()
613    };
614
615    // SAFETY: `entry` has the required size and remains valid for the call.
616    if unsafe { Thread32First(snapshot.raw(), &raw mut entry) } == 0 {
617        return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
618    }
619
620    loop {
621        if entry.th32OwnerProcessID == pid {
622            // SAFETY: The thread ID came from a live ToolHelp snapshot.
623            let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
624            if thread.is_null() {
625                return Err(io::Error::last_os_error());
626            }
627            return Ok(WindowsHandle(thread as isize));
628        }
629
630        // SAFETY: `entry` remains initialized with the required size.
631        if unsafe { Thread32Next(snapshot.raw(), &raw mut entry) } == 0 {
632            return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
633        }
634    }
635}
636
637#[cfg(windows)]
638fn thread_enumeration_error(pid: u32, no_more_files: u32) -> io::Error {
639    let error = io::Error::last_os_error();
640    if error.raw_os_error() == i32::try_from(no_more_files).ok() {
641        return io::Error::new(
642            io::ErrorKind::NotFound,
643            format!("no thread found for fallow subprocess {pid}"),
644        );
645    }
646    error
647}
648
649#[cfg(windows)]
650#[expect(
651    unsafe_code,
652    reason = "resuming a Windows thread requires ResumeThread"
653)]
654fn resume_thread(thread: &WindowsHandle) -> io::Result<()> {
655    use windows_sys::Win32::System::Threading::ResumeThread;
656
657    // SAFETY: The handle was opened with THREAD_SUSPEND_RESUME access.
658    let previous_count = unsafe { ResumeThread(thread.raw()) };
659    if previous_count == u32::MAX {
660        return Err(io::Error::last_os_error());
661    }
662    if previous_count == 0 {
663        return Err(io::Error::other(
664            "fallow subprocess primary thread was not suspended",
665        ));
666    }
667
668    Ok(())
669}