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        // SAFETY: `info` points to writable storage for a siginfo_t. WNOWAIT
301        // observes the dedicated child without releasing its PID or PGID.
302        let result = unsafe {
303            libc::waitid(
304                libc::P_PID,
305                self.process_group_id as libc::id_t,
306                info.as_mut_ptr(),
307                libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
308            )
309        };
310        if result != 0 {
311            return Err(io::Error::last_os_error());
312        }
313
314        // SAFETY: waitid initialized the siginfo_t on success. A zero si_pid
315        // means WNOHANG observed no state change yet.
316        let exited = unsafe { info.assume_init().si_pid() } != 0;
317        // Only the apple EPERM branch of `terminate` reads this cache.
318        #[cfg(target_vendor = "apple")]
319        if exited {
320            self.leader_exit_observed
321                .store(true, std::sync::atomic::Ordering::Relaxed);
322        }
323        Ok(exited)
324    }
325
326    /// Terminate the complete owned process tree.
327    #[cfg(windows)]
328    #[expect(
329        unsafe_code,
330        reason = "Windows Job Object termination requires a Win32 FFI call"
331    )]
332    pub fn terminate(&self) -> io::Result<()> {
333        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
334
335        // SAFETY: The handle remains owned by this ProcessTree until Drop.
336        if unsafe { TerminateJobObject(self.job.raw(), 1) } != 0 {
337            return Ok(());
338        }
339        Err(io::Error::last_os_error())
340    }
341
342    /// Report that process-tree termination is unavailable.
343    #[cfg(not(any(unix, windows)))]
344    pub fn terminate(&self) -> io::Result<()> {
345        Err(io::Error::new(
346            io::ErrorKind::Unsupported,
347            "process-tree termination is unsupported on this platform",
348        ))
349    }
350
351    #[cfg(unix)]
352    #[expect(
353        unsafe_code,
354        reason = "POSIX process-group liveness checks require libc::kill"
355    )]
356    pub(crate) fn is_alive(&self) -> bool {
357        // SAFETY: Signal 0 checks existence without delivering a signal.
358        unsafe { libc::kill(-self.process_group_id, 0) == 0 }
359    }
360
361    #[cfg(windows)]
362    #[expect(
363        unsafe_code,
364        reason = "Windows Job Object liveness requires QueryInformationJobObject"
365    )]
366    pub(crate) fn is_alive(&self) -> bool {
367        use std::mem;
368        use std::ptr;
369
370        use windows_sys::Win32::System::JobObjects::{
371            JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JobObjectBasicAccountingInformation,
372            QueryInformationJobObject,
373        };
374
375        let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default();
376        // SAFETY: The output buffer has the requested information-class layout
377        // and remains writable for the call.
378        unsafe {
379            QueryInformationJobObject(
380                self.job.raw(),
381                JobObjectBasicAccountingInformation,
382                (&raw mut accounting).cast(),
383                mem::size_of_val(&accounting) as u32,
384                ptr::null_mut(),
385            ) != 0
386                && accounting.ActiveProcesses > 0
387        }
388    }
389
390    #[cfg(not(any(unix, windows)))]
391    pub(crate) fn is_alive(&self) -> bool {
392        false
393    }
394}
395
396/// Outcome of a bounded child cleanup attempt.
397pub struct ChildCleanup {
398    /// Reaped child status, when it became available within the cleanup budget.
399    pub status: Option<ExitStatus>,
400    /// Cleanup diagnostics that should be attached to the caller's error.
401    pub errors: Vec<String>,
402}
403
404#[cfg(feature = "tokio")]
405/// Terminate and reap a Tokio child with bounded retries.
406pub async fn cleanup_tokio_child(
407    process_tree: Option<&ProcessTree>,
408    child: &mut tokio::process::Child,
409) -> ChildCleanup {
410    let mut errors = Vec::new();
411    if !request_tree_termination(process_tree, &mut errors)
412        && let Err(error) = child.start_kill()
413    {
414        errors.push(format!("failed to kill direct subprocess: {error}"));
415    }
416
417    let status = match tokio::time::timeout(CLEANUP_GRACE, child.wait()).await {
418        Ok(Ok(status)) => {
419            return ChildCleanup {
420                status: Some(status),
421                errors,
422            };
423        }
424        Ok(Err(error)) => {
425            errors.push(format!("failed to reap direct subprocess: {error}"));
426            return ChildCleanup {
427                status: None,
428                errors,
429            };
430        }
431        Err(_) => {
432            errors.push(format!(
433                "direct subprocess did not exit within {}ms cleanup grace",
434                CLEANUP_GRACE.as_millis()
435            ));
436            None
437        }
438    };
439
440    if let Err(error) = child.start_kill() {
441        errors.push(format!("failed to retry direct subprocess kill: {error}"));
442    }
443    let status = match tokio::time::timeout(REAP_RETRY_GRACE, child.wait()).await {
444        Ok(Ok(status)) => Some(status),
445        Ok(Err(error)) => {
446            errors.push(format!(
447                "failed to reap direct subprocess after retry: {error}"
448            ));
449            status
450        }
451        Err(_) => {
452            errors.push(format!(
453                "direct subprocess still did not exit after {}ms kill retry",
454                REAP_RETRY_GRACE.as_millis()
455            ));
456            status
457        }
458    };
459    ChildCleanup { status, errors }
460}
461
462/// Terminate and reap a standard-library child with bounded retries.
463pub fn cleanup_std_child(
464    process_tree: Option<&ProcessTree>,
465    child: &mut std::process::Child,
466) -> ChildCleanup {
467    let mut errors = Vec::new();
468    if !request_tree_termination(process_tree, &mut errors)
469        && let Err(error) = child.kill()
470    {
471        errors.push(format!("failed to kill direct subprocess: {error}"));
472    }
473
474    let status = match poll_std_child(child, CLEANUP_GRACE) {
475        Ok(Some(status)) => {
476            return ChildCleanup {
477                status: Some(status),
478                errors,
479            };
480        }
481        Ok(None) => {
482            errors.push(format!(
483                "direct subprocess did not exit within {}ms cleanup grace",
484                CLEANUP_GRACE.as_millis()
485            ));
486            None
487        }
488        Err(error) => {
489            errors.push(format!("failed to reap direct subprocess: {error}"));
490            return ChildCleanup {
491                status: None,
492                errors,
493            };
494        }
495    };
496
497    if let Err(error) = child.kill() {
498        errors.push(format!("failed to retry direct subprocess kill: {error}"));
499    }
500    let status = match poll_std_child(child, REAP_RETRY_GRACE) {
501        Ok(Some(status)) => Some(status),
502        Ok(None) => {
503            errors.push(format!(
504                "direct subprocess still did not exit after {}ms kill retry",
505                REAP_RETRY_GRACE.as_millis()
506            ));
507            status
508        }
509        Err(error) => {
510            errors.push(format!(
511                "failed to reap direct subprocess after retry: {error}"
512            ));
513            status
514        }
515    };
516    ChildCleanup { status, errors }
517}
518
519fn request_tree_termination(process_tree: Option<&ProcessTree>, errors: &mut Vec<String>) -> bool {
520    let Some(process_tree) = process_tree else {
521        return false;
522    };
523    match process_tree.terminate() {
524        Ok(()) => true,
525        Err(error) => {
526            errors.push(format!("failed to terminate subprocess tree: {error}"));
527            false
528        }
529    }
530}
531
532fn poll_std_child(
533    child: &mut std::process::Child,
534    grace: Duration,
535) -> io::Result<Option<ExitStatus>> {
536    let deadline = Instant::now() + grace;
537    loop {
538        if let Some(status) = child.try_wait()? {
539            return Ok(Some(status));
540        }
541
542        let remaining = deadline.saturating_duration_since(Instant::now());
543        if remaining.is_zero() {
544            return Ok(None);
545        }
546        std::thread::sleep(CLEANUP_POLL_INTERVAL.min(remaining));
547    }
548}
549
550#[cfg(all(test, feature = "tokio", unix))]
551#[expect(
552    clippy::expect_used,
553    reason = "test setup failures should fail at the exact setup operation"
554)]
555mod tests {
556    use super::*;
557
558    #[tokio::test]
559    #[expect(
560        unsafe_code,
561        reason = "the regression test verifies that the observed PID remains reserved"
562    )]
563    async fn non_reaping_exit_observation_reserves_process_group_identity() {
564        let mut command = tokio::process::Command::new("/bin/sh");
565        command.args(["-c", "exit 0"]);
566        configure_tokio_command(&mut command);
567        let mut child = command.spawn().expect("test subprocess");
568        let pid = child.id().expect("test subprocess PID");
569        let process_tree = ProcessTree::for_tokio_child(&child).expect("test process tree");
570
571        tokio::time::timeout(
572            Duration::from_secs(1),
573            process_tree.wait_for_exit_without_reaping(),
574        )
575        .await
576        .expect("test subprocess exit")
577        .expect("non-reaping exit observation");
578        // SAFETY: Signal zero only checks whether the captured PID still exists.
579        let leader_is_reserved = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
580        let cleanup = cleanup_tokio_child(Some(&process_tree), &mut child).await;
581
582        assert!(leader_is_reserved, "subprocess leader was reaped too early");
583        assert!(cleanup.errors.is_empty(), "{:?}", cleanup.errors);
584        assert!(cleanup.status.is_some_and(|status| status.success()));
585    }
586}
587
588#[cfg(windows)]
589fn resume_suspended_process(pid: u32) -> io::Result<()> {
590    const THREAD_DISCOVERY_ATTEMPTS: usize = 20;
591    const THREAD_DISCOVERY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
592
593    for _ in 0..THREAD_DISCOVERY_ATTEMPTS {
594        match find_process_thread(pid) {
595            Ok(thread) => return resume_thread(&thread),
596            Err(error) if error.kind() == io::ErrorKind::NotFound => {
597                std::thread::sleep(THREAD_DISCOVERY_DELAY);
598            }
599            Err(error) => return Err(error),
600        }
601    }
602
603    Err(io::Error::new(
604        io::ErrorKind::NotFound,
605        format!("could not find suspended primary thread for fallow subprocess {pid}"),
606    ))
607}
608
609#[cfg(windows)]
610#[expect(
611    unsafe_code,
612    reason = "thread discovery requires Windows ToolHelp FFI calls"
613)]
614fn find_process_thread(pid: u32) -> io::Result<WindowsHandle> {
615    use std::mem;
616
617    use windows_sys::Win32::Foundation::{ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE};
618    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
619        CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
620    };
621    use windows_sys::Win32::System::Threading::{OpenThread, THREAD_SUSPEND_RESUME};
622
623    // SAFETY: The flags and process ID follow the ToolHelp API contract.
624    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
625    if snapshot == INVALID_HANDLE_VALUE {
626        return Err(io::Error::last_os_error());
627    }
628    let snapshot = WindowsHandle(snapshot as isize);
629    let mut entry = THREADENTRY32 {
630        dwSize: mem::size_of::<THREADENTRY32>() as u32,
631        ..THREADENTRY32::default()
632    };
633
634    // SAFETY: `entry` has the required size and remains valid for the call.
635    if unsafe { Thread32First(snapshot.raw(), &raw mut entry) } == 0 {
636        return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
637    }
638
639    loop {
640        if entry.th32OwnerProcessID == pid {
641            // SAFETY: The thread ID came from a live ToolHelp snapshot.
642            let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
643            if thread.is_null() {
644                return Err(io::Error::last_os_error());
645            }
646            return Ok(WindowsHandle(thread as isize));
647        }
648
649        // SAFETY: `entry` remains initialized with the required size.
650        if unsafe { Thread32Next(snapshot.raw(), &raw mut entry) } == 0 {
651            return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
652        }
653    }
654}
655
656#[cfg(windows)]
657fn thread_enumeration_error(pid: u32, no_more_files: u32) -> io::Error {
658    let error = io::Error::last_os_error();
659    if error.raw_os_error() == i32::try_from(no_more_files).ok() {
660        return io::Error::new(
661            io::ErrorKind::NotFound,
662            format!("no thread found for fallow subprocess {pid}"),
663        );
664    }
665    error
666}
667
668#[cfg(windows)]
669#[expect(
670    unsafe_code,
671    reason = "resuming a Windows thread requires ResumeThread"
672)]
673fn resume_thread(thread: &WindowsHandle) -> io::Result<()> {
674    use windows_sys::Win32::System::Threading::ResumeThread;
675
676    // SAFETY: The handle was opened with THREAD_SUSPEND_RESUME access.
677    let previous_count = unsafe { ResumeThread(thread.raw()) };
678    if previous_count == u32::MAX {
679        return Err(io::Error::last_os_error());
680    }
681    if previous_count == 0 {
682        return Err(io::Error::other(
683            "fallow subprocess primary thread was not suspended",
684        ));
685    }
686
687    Ok(())
688}