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(all(unix, target_vendor = "apple"))]
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            #[cfg(target_vendor = "apple")]
184            leader_exit_observed: std::sync::atomic::AtomicBool::new(false),
185        })
186    }
187
188    #[cfg(windows)]
189    #[expect(unsafe_code, reason = "Windows Job Objects require Win32 FFI calls")]
190    fn for_windows_handle(process: std::os::windows::io::RawHandle, pid: u32) -> io::Result<Self> {
191        use std::mem;
192        use std::ptr;
193
194        use windows_sys::Win32::System::JobObjects::{
195            AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
196            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
197            SetInformationJobObject,
198        };
199
200        // SAFETY: Both pointers are null by contract, creating an unnamed job
201        // with default security attributes.
202        let job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
203        if job.is_null() {
204            return Err(io::Error::last_os_error());
205        }
206        let job = WindowsJobGuard::new(WindowsHandle(job as isize));
207        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
208        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
209
210        // SAFETY: The buffer has the exact information-class layout and remains
211        // alive for the duration of this call.
212        if unsafe {
213            SetInformationJobObject(
214                job.raw()?,
215                JobObjectExtendedLimitInformation,
216                (&raw const limits).cast(),
217                mem::size_of_val(&limits) as u32,
218            )
219        } == 0
220        {
221            return Err(io::Error::last_os_error());
222        }
223
224        // SAFETY: `job` is a live handle from CreateJobObjectW and `process` is
225        // borrowed from the freshly spawned child for the duration of this call.
226        if unsafe { AssignProcessToJobObject(job.raw()?, process.cast()) } == 0 {
227            return Err(io::Error::last_os_error());
228        }
229
230        resume_suspended_process(pid)?;
231        Ok(Self { job: job.disarm()? })
232    }
233
234    /// Terminate the complete owned process tree.
235    #[cfg(unix)]
236    #[expect(
237        unsafe_code,
238        reason = "POSIX process-group termination requires libc::kill"
239    )]
240    pub fn terminate(&self) -> io::Result<()> {
241        // SAFETY: A negative PID targets the dedicated process group created by
242        // `process_group(0)`. SIGKILL has no borrowed-memory requirements.
243        if unsafe { libc::kill(-self.process_group_id, libc::SIGKILL) } == 0 {
244            return Ok(());
245        }
246
247        let error = io::Error::last_os_error();
248        if error.raw_os_error() == Some(libc::ESRCH) {
249            return Ok(());
250        }
251        #[cfg(target_vendor = "apple")]
252        // macOS returns EPERM when the reserved process group contains only
253        // the zombie leader, so there are no live members to signal. Ask the
254        // kernel now rather than trusting an earlier poll: a leader that exits
255        // between the last observation and this call would otherwise surface as
256        // a spurious termination error in caller-visible output.
257        if error.raw_os_error() == Some(libc::EPERM) && self.leader_has_exited() {
258            return Ok(());
259        }
260        Err(error)
261    }
262
263    /// Report whether the child leader has already exited, refreshing the
264    /// cached observation. A leader reaped elsewhere reports `ECHILD` and also
265    /// counts as exited, since termination has nothing left to signal.
266    #[cfg(all(unix, target_vendor = "apple"))]
267    fn leader_has_exited(&self) -> bool {
268        if self
269            .leader_exit_observed
270            .load(std::sync::atomic::Ordering::Relaxed)
271        {
272            return true;
273        }
274        match self.has_exited_without_reaping() {
275            Ok(exited) => exited,
276            Err(error) => error.raw_os_error() == Some(libc::ECHILD),
277        }
278    }
279
280    /// Wait until the child leader exits without reaping its process-group ID.
281    #[cfg(all(feature = "tokio", unix))]
282    pub async fn wait_for_exit_without_reaping(&self) -> io::Result<()> {
283        loop {
284            if self.has_exited_without_reaping()? {
285                return Ok(());
286            }
287            tokio::time::sleep(CLEANUP_POLL_INTERVAL).await;
288        }
289    }
290
291    /// Check whether the child leader exited while preserving its process-group
292    /// identity for safe descendant cleanup.
293    #[cfg(unix)]
294    #[expect(
295        unsafe_code,
296        reason = "non-reaping POSIX child observation requires waitid"
297    )]
298    pub fn has_exited_without_reaping(&self) -> io::Result<bool> {
299        let mut info = std::mem::MaybeUninit::<libc::siginfo_t>::zeroed();
300        let process_id = libc::id_t::try_from(self.process_group_id)
301            .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?;
302        // SAFETY: `info` points to writable storage for a siginfo_t. WNOWAIT
303        // observes the dedicated child without releasing its PID or PGID.
304        let result = unsafe {
305            libc::waitid(
306                libc::P_PID,
307                process_id,
308                info.as_mut_ptr(),
309                libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
310            )
311        };
312        if result != 0 {
313            return Err(io::Error::last_os_error());
314        }
315
316        // SAFETY: waitid initialized the siginfo_t on success.
317        let info = unsafe { info.assume_init() };
318        // SAFETY: si_pid reads initialized memory: waitid sets it on a state
319        // change, and the zeroed buffer keeps it 0 when WNOHANG finds none.
320        let exited = unsafe { info.si_pid() } != 0;
321        // Only the apple EPERM branch of `terminate` reads this cache.
322        #[cfg(target_vendor = "apple")]
323        if exited {
324            self.leader_exit_observed
325                .store(true, std::sync::atomic::Ordering::Relaxed);
326        }
327        Ok(exited)
328    }
329
330    /// Terminate the complete owned process tree.
331    #[cfg(windows)]
332    #[expect(
333        unsafe_code,
334        reason = "Windows Job Object termination requires a Win32 FFI call"
335    )]
336    pub fn terminate(&self) -> io::Result<()> {
337        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
338
339        // SAFETY: The handle remains owned by this ProcessTree until Drop.
340        if unsafe { TerminateJobObject(self.job.raw(), 1) } != 0 {
341            return Ok(());
342        }
343        Err(io::Error::last_os_error())
344    }
345
346    /// Report that process-tree termination is unavailable.
347    #[cfg(not(any(unix, windows)))]
348    pub fn terminate(&self) -> io::Result<()> {
349        Err(io::Error::new(
350            io::ErrorKind::Unsupported,
351            "process-tree termination is unsupported on this platform",
352        ))
353    }
354
355    #[cfg(unix)]
356    #[expect(
357        unsafe_code,
358        reason = "POSIX process-group liveness checks require libc::kill"
359    )]
360    pub(crate) fn is_alive(&self) -> bool {
361        // A group that holds only zombies still answers signal 0.
362        #[cfg(target_os = "linux")]
363        if let Some(running) =
364            crate::proc_state::process_group_has_running_member(self.process_group_id)
365        {
366            return running;
367        }
368        // SAFETY: Signal 0 checks existence without delivering a signal.
369        unsafe { libc::kill(-self.process_group_id, 0) == 0 }
370    }
371
372    #[cfg(windows)]
373    #[expect(
374        unsafe_code,
375        reason = "Windows Job Object liveness requires QueryInformationJobObject"
376    )]
377    pub(crate) fn is_alive(&self) -> bool {
378        use std::mem;
379        use std::ptr;
380
381        use windows_sys::Win32::System::JobObjects::{
382            JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JobObjectBasicAccountingInformation,
383            QueryInformationJobObject,
384        };
385
386        let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default();
387        // SAFETY: The output buffer has the requested information-class layout
388        // and remains writable for the call.
389        unsafe {
390            QueryInformationJobObject(
391                self.job.raw(),
392                JobObjectBasicAccountingInformation,
393                (&raw mut accounting).cast(),
394                mem::size_of_val(&accounting) as u32,
395                ptr::null_mut(),
396            ) != 0
397                && accounting.ActiveProcesses > 0
398        }
399    }
400
401    #[cfg(not(any(unix, windows)))]
402    pub(crate) fn is_alive(&self) -> bool {
403        false
404    }
405}
406
407/// Outcome of a bounded child cleanup attempt.
408pub struct ChildCleanup {
409    /// Reaped child status, when it became available within the cleanup budget.
410    pub status: Option<ExitStatus>,
411    /// Cleanup diagnostics that should be attached to the caller's error.
412    pub errors: Vec<String>,
413}
414
415#[cfg(feature = "tokio")]
416/// Terminate and reap a Tokio child with bounded retries.
417pub async fn cleanup_tokio_child(
418    process_tree: Option<&ProcessTree>,
419    child: &mut tokio::process::Child,
420) -> ChildCleanup {
421    let mut errors = Vec::new();
422    if !request_tree_termination(process_tree, &mut errors)
423        && let Err(error) = child.start_kill()
424    {
425        errors.push(format!("failed to kill direct subprocess: {error}"));
426    }
427
428    let status = match tokio::time::timeout(CLEANUP_GRACE, child.wait()).await {
429        Ok(Ok(status)) => {
430            return ChildCleanup {
431                status: Some(status),
432                errors,
433            };
434        }
435        Ok(Err(error)) => {
436            errors.push(format!("failed to reap direct subprocess: {error}"));
437            return ChildCleanup {
438                status: None,
439                errors,
440            };
441        }
442        Err(_) => {
443            errors.push(format!(
444                "direct subprocess did not exit within {}ms cleanup grace",
445                CLEANUP_GRACE.as_millis()
446            ));
447            None
448        }
449    };
450
451    if let Err(error) = child.start_kill() {
452        errors.push(format!("failed to retry direct subprocess kill: {error}"));
453    }
454    let status = match tokio::time::timeout(REAP_RETRY_GRACE, child.wait()).await {
455        Ok(Ok(status)) => Some(status),
456        Ok(Err(error)) => {
457            errors.push(format!(
458                "failed to reap direct subprocess after retry: {error}"
459            ));
460            status
461        }
462        Err(_) => {
463            errors.push(format!(
464                "direct subprocess still did not exit after {}ms kill retry",
465                REAP_RETRY_GRACE.as_millis()
466            ));
467            status
468        }
469    };
470    ChildCleanup { status, errors }
471}
472
473/// Terminate and reap a standard-library child with bounded retries.
474pub fn cleanup_std_child(
475    process_tree: Option<&ProcessTree>,
476    child: &mut std::process::Child,
477) -> ChildCleanup {
478    let mut errors = Vec::new();
479    if !request_tree_termination(process_tree, &mut errors)
480        && let Err(error) = child.kill()
481    {
482        errors.push(format!("failed to kill direct subprocess: {error}"));
483    }
484
485    let status = match poll_std_child(child, CLEANUP_GRACE) {
486        Ok(Some(status)) => {
487            return ChildCleanup {
488                status: Some(status),
489                errors,
490            };
491        }
492        Ok(None) => {
493            errors.push(format!(
494                "direct subprocess did not exit within {}ms cleanup grace",
495                CLEANUP_GRACE.as_millis()
496            ));
497            None
498        }
499        Err(error) => {
500            errors.push(format!("failed to reap direct subprocess: {error}"));
501            return ChildCleanup {
502                status: None,
503                errors,
504            };
505        }
506    };
507
508    if let Err(error) = child.kill() {
509        errors.push(format!("failed to retry direct subprocess kill: {error}"));
510    }
511    let status = match poll_std_child(child, REAP_RETRY_GRACE) {
512        Ok(Some(status)) => Some(status),
513        Ok(None) => {
514            errors.push(format!(
515                "direct subprocess still did not exit after {}ms kill retry",
516                REAP_RETRY_GRACE.as_millis()
517            ));
518            status
519        }
520        Err(error) => {
521            errors.push(format!(
522                "failed to reap direct subprocess after retry: {error}"
523            ));
524            status
525        }
526    };
527    ChildCleanup { status, errors }
528}
529
530fn request_tree_termination(process_tree: Option<&ProcessTree>, errors: &mut Vec<String>) -> bool {
531    let Some(process_tree) = process_tree else {
532        return false;
533    };
534    match process_tree.terminate() {
535        Ok(()) => true,
536        Err(error) => {
537            errors.push(format!("failed to terminate subprocess tree: {error}"));
538            false
539        }
540    }
541}
542
543fn poll_std_child(
544    child: &mut std::process::Child,
545    grace: Duration,
546) -> io::Result<Option<ExitStatus>> {
547    let deadline = Instant::now() + grace;
548    loop {
549        if let Some(status) = child.try_wait()? {
550            return Ok(Some(status));
551        }
552
553        let remaining = deadline.saturating_duration_since(Instant::now());
554        if remaining.is_zero() {
555            return Ok(None);
556        }
557        std::thread::sleep(CLEANUP_POLL_INTERVAL.min(remaining));
558    }
559}
560
561#[cfg(all(test, feature = "tokio", unix))]
562#[expect(
563    clippy::expect_used,
564    reason = "test setup failures should fail at the exact setup operation"
565)]
566mod tests {
567    use super::*;
568
569    #[tokio::test]
570    #[expect(
571        unsafe_code,
572        reason = "the regression test verifies that the observed PID remains reserved"
573    )]
574    async fn non_reaping_exit_observation_reserves_process_group_identity() {
575        let mut command = tokio::process::Command::new("/bin/sh");
576        command.args(["-c", "exit 0"]);
577        configure_tokio_command(&mut command);
578        let mut child = command.spawn().expect("test subprocess");
579        let pid = child.id().expect("test subprocess PID");
580        let process_tree = ProcessTree::for_tokio_child(&child).expect("test process tree");
581
582        tokio::time::timeout(
583            Duration::from_secs(1),
584            process_tree.wait_for_exit_without_reaping(),
585        )
586        .await
587        .expect("test subprocess exit")
588        .expect("non-reaping exit observation");
589        // SAFETY: Signal zero only checks whether the captured PID still exists.
590        let leader_is_reserved = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
591        let cleanup = cleanup_tokio_child(Some(&process_tree), &mut child).await;
592
593        assert!(leader_is_reserved, "subprocess leader was reaped too early");
594        assert!(cleanup.errors.is_empty(), "{:?}", cleanup.errors);
595        assert!(cleanup.status.is_some_and(|status| status.success()));
596    }
597}
598
599#[cfg(windows)]
600fn resume_suspended_process(pid: u32) -> io::Result<()> {
601    const THREAD_DISCOVERY_ATTEMPTS: usize = 20;
602    const THREAD_DISCOVERY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
603
604    for _ in 0..THREAD_DISCOVERY_ATTEMPTS {
605        match find_process_thread(pid) {
606            Ok(thread) => return resume_thread(&thread),
607            Err(error) if error.kind() == io::ErrorKind::NotFound => {
608                std::thread::sleep(THREAD_DISCOVERY_DELAY);
609            }
610            Err(error) => return Err(error),
611        }
612    }
613
614    Err(io::Error::new(
615        io::ErrorKind::NotFound,
616        format!("could not find suspended primary thread for fallow subprocess {pid}"),
617    ))
618}
619
620#[cfg(windows)]
621#[expect(
622    unsafe_code,
623    reason = "thread discovery requires Windows ToolHelp FFI calls"
624)]
625fn find_process_thread(pid: u32) -> io::Result<WindowsHandle> {
626    use std::mem;
627
628    use windows_sys::Win32::Foundation::{ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE};
629    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
630        CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
631    };
632    use windows_sys::Win32::System::Threading::{OpenThread, THREAD_SUSPEND_RESUME};
633
634    // SAFETY: The flags and process ID follow the ToolHelp API contract.
635    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
636    if snapshot == INVALID_HANDLE_VALUE {
637        return Err(io::Error::last_os_error());
638    }
639    let snapshot = WindowsHandle(snapshot as isize);
640    let mut entry = THREADENTRY32 {
641        dwSize: mem::size_of::<THREADENTRY32>() as u32,
642        ..THREADENTRY32::default()
643    };
644
645    // SAFETY: `entry` has the required size and remains valid for the call.
646    if unsafe { Thread32First(snapshot.raw(), &raw mut entry) } == 0 {
647        return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
648    }
649
650    loop {
651        if entry.th32OwnerProcessID == pid {
652            // SAFETY: The thread ID came from a live ToolHelp snapshot.
653            let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
654            if thread.is_null() {
655                return Err(io::Error::last_os_error());
656            }
657            return Ok(WindowsHandle(thread as isize));
658        }
659
660        // SAFETY: `entry` remains initialized with the required size.
661        if unsafe { Thread32Next(snapshot.raw(), &raw mut entry) } == 0 {
662            return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
663        }
664    }
665}
666
667#[cfg(windows)]
668fn thread_enumeration_error(pid: u32, no_more_files: u32) -> io::Error {
669    let error = io::Error::last_os_error();
670    if error.raw_os_error() == i32::try_from(no_more_files).ok() {
671        return io::Error::new(
672            io::ErrorKind::NotFound,
673            format!("no thread found for fallow subprocess {pid}"),
674        );
675    }
676    error
677}
678
679#[cfg(windows)]
680#[expect(
681    unsafe_code,
682    reason = "resuming a Windows thread requires ResumeThread"
683)]
684fn resume_thread(thread: &WindowsHandle) -> io::Result<()> {
685    use windows_sys::Win32::System::Threading::ResumeThread;
686
687    // SAFETY: The handle was opened with THREAD_SUSPEND_RESUME access.
688    let previous_count = unsafe { ResumeThread(thread.raw()) };
689    if previous_count == u32::MAX {
690        return Err(io::Error::last_os_error());
691    }
692    if previous_count == 0 {
693        return Err(io::Error::other(
694            "fallow subprocess primary thread was not suspended",
695        ));
696    }
697
698    Ok(())
699}