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