Skip to main content

rightkit_process/
owned.rs

1use std::{
2    ffi::OsStr,
3    io,
4    num::NonZeroU32,
5    process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus},
6    sync::{
7        atomic::{AtomicU32, Ordering},
8        Arc,
9    },
10    thread,
11    time::{Duration, Instant},
12};
13
14use process_wrap::std::{StdChildWrapper, StdCommandWrap, StdCommandWrapper};
15
16#[derive(Debug)]
17pub struct OwnedCommand {
18    command: Command,
19    windows_hidden: bool,
20    caller_job: CallerJob,
21}
22
23/// The caller's job object (Windows), duplicated until the child has been assigned to it.
24#[cfg(windows)]
25type CallerJob = Option<std::os::windows::io::OwnedHandle>;
26#[cfg(not(windows))]
27type CallerJob = Option<std::convert::Infallible>;
28
29impl OwnedCommand {
30    pub fn new(program: impl AsRef<OsStr>) -> Self {
31        // windowless: from_command enables CREATE_NO_WINDOW for every OwnedCommand.
32        Self::from_command(Command::new(program))
33    }
34
35    /// On Windows the child gets CREATE_NO_WINDOW by default: RightKit hosts are GUI apps and
36    /// background services, so a console window would only ever flash on the user's desktop.
37    pub fn from_command(command: Command) -> Self {
38        Self {
39            command,
40            windows_hidden: true,
41            caller_job: Default::default(),
42        }
43    }
44
45    pub fn command_mut(&mut self) -> &mut Command {
46        &mut self.command
47    }
48
49    /// Run the child with a scrubbed environment: everything is cleared, then
50    /// only the named variables that are set in this process are copied
51    /// across (HeardRight's sidecar whitelist). Include `PATH` and the
52    /// platform basics yourself, or start from [`DEFAULT_ENV_ALLOWLIST`].
53    /// Variables set explicitly afterwards through [`Self::command_mut`] win.
54    pub fn env_allowlist<I, S>(&mut self, keys: I) -> &mut Self
55    where
56        I: IntoIterator<Item = S>,
57        S: AsRef<OsStr>,
58    {
59        let kept: Vec<_> = keys
60            .into_iter()
61            .filter_map(|k| std::env::var_os(k.as_ref()).map(|v| (k.as_ref().to_os_string(), v)))
62            .collect();
63        self.command.env_clear();
64        for (k, v) in kept {
65            self.command.env(k, v);
66        }
67        self
68    }
69
70    /// Remove the named variables from the child's inherited environment
71    /// (secrets, BYOK keys) without clearing the rest.
72    pub fn env_strip<I, S>(&mut self, keys: I) -> &mut Self
73    where
74        I: IntoIterator<Item = S>,
75        S: AsRef<OsStr>,
76    {
77        for k in keys {
78            self.command.env_remove(k);
79        }
80        self
81    }
82
83    /// Kept for callers written before hiding became the default; it is now a no-op.
84    pub fn windows_hide(&mut self) -> &mut Self {
85        self.windows_hidden = true;
86        self
87    }
88
89    /// Windows: also bind the child to the caller's job object (an in-process host's own
90    /// job), before the child runs its first instruction. RightKit's kill-on-close jobs nest
91    /// beneath it and none of them allow breakaway, so terminating or closing the caller's
92    /// job kills the child and everything it spawned. The handle is duplicated here and the
93    /// duplicate is closed as soon as the child has been assigned.
94    #[cfg(windows)]
95    pub fn windows_job(
96        &mut self,
97        job: std::os::windows::io::BorrowedHandle<'_>,
98    ) -> io::Result<&mut Self> {
99        self.caller_job = Some(job.try_clone_to_owned()?);
100        Ok(self)
101    }
102
103    pub fn spawn(self) -> io::Result<OwnedChild> {
104        spawn_owned(
105            self.command,
106            self.windows_hidden,
107            false,
108            None,
109            self.caller_job,
110        )
111    }
112}
113
114/// Variables a sidecar normally needs to run at all; a base for
115/// [`OwnedCommand::env_allowlist`].
116pub const DEFAULT_ENV_ALLOWLIST: &[&str] = &[
117    "PATH",
118    "HOME",
119    "USER",
120    "LOGNAME",
121    "LANG",
122    "LC_ALL",
123    "TMPDIR",
124    "TEMP",
125    "TMP",
126    "SystemRoot",
127    "SYSTEMROOT",
128    "USERPROFILE",
129    "APPDATA",
130    "LOCALAPPDATA",
131    "ProgramData",
132    "COMSPEC",
133    "PATHEXT",
134    "XDG_DATA_HOME",
135    "XDG_RUNTIME_DIR",
136];
137
138#[derive(Debug)]
139pub struct OwnedChild {
140    child: Option<Box<dyn StdChildWrapper>>,
141}
142
143/// Result of [`OwnedChild::terminate_tree_bounded`].
144#[derive(Clone, Copy, Debug, Eq, PartialEq)]
145pub enum ReapOutcome {
146    /// Already gone, or the exit was observed within the bound.
147    Exited(ExitStatus),
148    /// The kill was issued but the OS did not report the exit in time (a
149    /// process wedged in the kernel, e.g. releasing a GPU model). The caller is
150    /// not blocked; the owned handle still reaps on drop.
151    TimedOut,
152}
153
154#[derive(Clone, Copy, Debug, Eq, PartialEq)]
155pub enum WaitOutcome {
156    Exited(ExitStatus),
157    Terminated(ExitStatus),
158}
159
160impl OwnedChild {
161    pub fn id(&self) -> u32 {
162        self.child().id()
163    }
164
165    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
166        self.child_mut().stdin().take()
167    }
168
169    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
170        self.child_mut().stdout().take()
171    }
172
173    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
174        self.child_mut().stderr().take()
175    }
176
177    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
178        self.child_mut().try_wait()
179    }
180
181    pub fn wait(&mut self) -> io::Result<ExitStatus> {
182        self.child_mut().wait()
183    }
184
185    pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result<Option<ExitStatus>> {
186        let started = Instant::now();
187        loop {
188            if let Some(status) = self.try_wait()? {
189                return Ok(Some(status));
190            }
191            if started.elapsed() >= timeout {
192                return Ok(None);
193            }
194            thread::sleep(Duration::from_millis(5).min(timeout.saturating_sub(started.elapsed())));
195        }
196    }
197
198    pub fn wait_or_kill(&mut self, grace: Duration) -> io::Result<WaitOutcome> {
199        #[cfg(unix)]
200        self.child().signal(nix::libc::SIGTERM)?;
201
202        if let Some(status) = self.wait_timeout(grace)? {
203            return Ok(WaitOutcome::Exited(status));
204        }
205        self.terminate_tree().map(WaitOutcome::Terminated)
206    }
207
208    pub fn terminate_tree(&mut self) -> io::Result<ExitStatus> {
209        if let Some(status) = self.try_wait()? {
210            return Ok(status);
211        }
212        self.child_mut().start_kill()?;
213        self.child_mut().wait()
214    }
215
216    /// Kill the whole tree now and wait at most `timeout` for the OS to report
217    /// the exit. Unlike [`Self::terminate_tree`] this never blocks past the
218    /// bound, so tray-quit / window-close paths stay responsive even if the
219    /// child cannot be reaped promptly (HeardRight's bounded reap).
220    pub fn terminate_tree_bounded(&mut self, timeout: Duration) -> io::Result<ReapOutcome> {
221        if let Some(status) = self.try_wait()? {
222            return Ok(ReapOutcome::Exited(status));
223        }
224        self.child_mut().start_kill()?;
225        Ok(match self.wait_timeout(timeout)? {
226            Some(status) => ReapOutcome::Exited(status),
227            None => ReapOutcome::TimedOut,
228        })
229    }
230
231    fn child(&self) -> &dyn StdChildWrapper {
232        self.child
233            .as_deref()
234            .expect("owned child is always present")
235    }
236
237    fn child_mut(&mut self) -> &mut dyn StdChildWrapper {
238        self.child
239            .as_deref_mut()
240            .expect("owned child is always present")
241    }
242}
243
244impl Drop for OwnedChild {
245    fn drop(&mut self) {
246        let _ = self.terminate_tree();
247    }
248}
249
250#[derive(Clone, Copy, Debug, Eq, PartialEq)]
251pub struct AdoptedProcess {
252    pid: NonZeroU32,
253}
254
255impl AdoptedProcess {
256    pub fn new(pid: NonZeroU32) -> Self {
257        Self { pid }
258    }
259
260    pub fn id(&self) -> u32 {
261        self.pid.get()
262    }
263
264    pub fn is_running(&self) -> io::Result<bool> {
265        process_is_running(self.pid)
266    }
267}
268
269#[derive(Debug)]
270struct CleanupWrapper {
271    captured_pid: Option<Arc<AtomicU32>>,
272    caller_job: CallerJob,
273}
274
275impl StdCommandWrapper for CleanupWrapper {
276    fn post_spawn(&mut self, child: &mut Child, _core: &StdCommandWrap) -> io::Result<()> {
277        if let Some(pid) = &self.captured_pid {
278            pid.store(child.id(), Ordering::SeqCst);
279        }
280        Ok(())
281    }
282
283    fn wrap_child(
284        &mut self,
285        #[cfg_attr(not(windows), allow(unused_mut))] mut child: Box<dyn StdChildWrapper>,
286        _core: &StdCommandWrap,
287    ) -> io::Result<Box<dyn StdChildWrapper>> {
288        // Runs first, while process-wrap's JobObject still holds the child suspended, so the
289        // child and everything it later spawns land in these jobs. The caller's job comes
290        // first: a process can join a non-empty job only while it belongs to none, and the
291        // empty RightKit jobs assigned after it then nest beneath it. A suspended child that
292        // cannot be bound is killed, never left behind.
293        #[cfg(not(windows))]
294        let _ = self.caller_job.take();
295        #[cfg(windows)]
296        let kill_on_close = {
297            let bound = match self.caller_job.take() {
298                Some(job) => assign_to_caller_job(&job, child.inner()),
299                None => Ok(()),
300            }
301            .and_then(|()| KillOnCloseJob::assign(child.inner()));
302            match bound {
303                Ok(job) => Some(job),
304                Err(error) => {
305                    let _ = child.start_kill();
306                    let _ = child.wait();
307                    return Err(error);
308                }
309            }
310        };
311        Ok(Box::new(CleanupChild {
312            child: Some(child),
313            #[cfg(windows)]
314            _kill_on_close: kill_on_close,
315        }))
316    }
317}
318
319#[cfg(windows)]
320fn assign_to_caller_job(job: &std::os::windows::io::OwnedHandle, child: &Child) -> io::Result<()> {
321    use std::os::windows::io::AsRawHandle;
322    use windows::Win32::{Foundation::HANDLE, System::JobObjects::AssignProcessToJobObject};
323    unsafe {
324        AssignProcessToJobObject(
325            HANDLE(job.as_raw_handle() as _),
326            HANDLE(child.as_raw_handle() as _),
327        )
328    }
329    .map_err(|e| io::Error::other(format!("bind child to the caller's job object: {e}")))
330}
331
332/// process-wrap's std `JobObject` never sets JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so a host that
333/// dies without running destructors (crash, TerminateProcess) left its tree running on Windows.
334/// This second job's only handle lives in the host; when the host goes away for any reason the
335/// kernel closes it and kills every process in the tree.
336#[cfg(windows)]
337#[derive(Debug)]
338struct KillOnCloseJob(windows::Win32::Foundation::HANDLE);
339
340#[cfg(windows)]
341impl KillOnCloseJob {
342    fn assign(child: &Child) -> io::Result<Self> {
343        use std::os::windows::io::AsRawHandle;
344        use windows::Win32::{
345            Foundation::HANDLE,
346            System::JobObjects::{
347                AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
348                SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
349                JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
350            },
351        };
352        let job = Self(unsafe { CreateJobObjectW(None, None) }.map_err(io::Error::other)?);
353        let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
354        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
355        unsafe {
356            SetInformationJobObject(
357                job.0,
358                JobObjectExtendedLimitInformation,
359                &info as *const _ as *const _,
360                std::mem::size_of_val(&info) as u32,
361            )
362        }
363        .map_err(io::Error::other)?;
364        unsafe { AssignProcessToJobObject(job.0, HANDLE(child.as_raw_handle() as _)) }
365            .map_err(io::Error::other)?;
366        Ok(job)
367    }
368}
369
370#[cfg(windows)]
371impl Drop for KillOnCloseJob {
372    fn drop(&mut self) {
373        let _ = unsafe { windows::Win32::Foundation::CloseHandle(self.0) };
374    }
375}
376
377// The raw job handle is only closed on drop; moving it between threads is sound.
378#[cfg(windows)]
379unsafe impl Send for KillOnCloseJob {}
380#[cfg(windows)]
381unsafe impl Sync for KillOnCloseJob {}
382
383#[derive(Debug)]
384struct CleanupChild {
385    child: Option<Box<dyn StdChildWrapper>>,
386    // Dropped after `child`'s own Drop ran; closing it kills anything still in the tree.
387    #[cfg(windows)]
388    _kill_on_close: Option<KillOnCloseJob>,
389}
390
391impl CleanupChild {
392    fn child(&self) -> &dyn StdChildWrapper {
393        self.child.as_deref().expect("cleanup child is present")
394    }
395
396    fn child_mut(&mut self) -> &mut dyn StdChildWrapper {
397        self.child.as_deref_mut().expect("cleanup child is present")
398    }
399}
400
401impl StdChildWrapper for CleanupChild {
402    fn inner(&self) -> &Child {
403        self.child().inner()
404    }
405
406    fn inner_mut(&mut self) -> &mut Child {
407        self.child_mut().inner_mut()
408    }
409
410    fn into_inner(mut self: Box<Self>) -> Child {
411        self.child
412            .take()
413            .expect("cleanup child is present")
414            .into_inner()
415    }
416
417    fn stdin(&mut self) -> &mut Option<ChildStdin> {
418        self.child_mut().stdin()
419    }
420
421    fn stdout(&mut self) -> &mut Option<ChildStdout> {
422        self.child_mut().stdout()
423    }
424
425    fn stderr(&mut self) -> &mut Option<ChildStderr> {
426        self.child_mut().stderr()
427    }
428
429    fn id(&self) -> u32 {
430        self.child().id()
431    }
432
433    fn start_kill(&mut self) -> io::Result<()> {
434        self.child_mut().start_kill()
435    }
436
437    fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
438        self.child_mut().try_wait()
439    }
440
441    fn wait(&mut self) -> io::Result<ExitStatus> {
442        self.child_mut().wait()
443    }
444
445    #[cfg(unix)]
446    fn signal(&self, signal: i32) -> io::Result<()> {
447        self.child().signal(signal)
448    }
449}
450
451impl Drop for CleanupChild {
452    fn drop(&mut self) {
453        let Some(child) = self.child.as_deref_mut() else {
454            return;
455        };
456        if !matches!(child.try_wait(), Ok(Some(_))) {
457            let _ = child.start_kill();
458            let _ = child.wait();
459        }
460    }
461}
462
463#[derive(Debug)]
464struct FailAfterSpawn;
465
466impl StdCommandWrapper for FailAfterSpawn {
467    fn wrap_child(
468        &mut self,
469        _child: Box<dyn StdChildWrapper>,
470        _core: &StdCommandWrap,
471    ) -> io::Result<Box<dyn StdChildWrapper>> {
472        Err(io::Error::other("injected post-spawn wrapping failure"))
473    }
474}
475
476fn spawn_owned(
477    command: Command,
478    windows_hidden: bool,
479    fail_after_spawn: bool,
480    captured_pid: Option<Arc<AtomicU32>>,
481    caller_job: CallerJob,
482) -> io::Result<OwnedChild> {
483    let mut command = StdCommandWrap::from(command);
484    command.wrap(CleanupWrapper {
485        captured_pid,
486        caller_job,
487    });
488
489    #[cfg(windows)]
490    {
491        use process_wrap::std::{CreationFlags, JobObject};
492        use windows::Win32::System::Threading::CREATE_NO_WINDOW;
493
494        if windows_hidden {
495            command.wrap(CreationFlags(CREATE_NO_WINDOW));
496        }
497        command.wrap(JobObject);
498    }
499
500    #[cfg(unix)]
501    {
502        use process_wrap::std::ProcessGroup;
503        let _ = windows_hidden;
504        command.wrap(ProcessGroup::leader());
505    }
506
507    if fail_after_spawn {
508        command.wrap(FailAfterSpawn);
509    }
510
511    command
512        .spawn()
513        .map(|child| OwnedChild { child: Some(child) })
514}
515
516#[cfg(test)]
517fn spawn_for_test(command: Command, captured_pid: Arc<AtomicU32>) -> io::Result<OwnedChild> {
518    spawn_owned(command, true, true, Some(captured_pid), Default::default())
519}
520
521#[cfg(windows)]
522fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
523    use windows::Win32::{
524        Foundation::{CloseHandle, ERROR_INVALID_PARAMETER, WAIT_TIMEOUT},
525        System::Threading::{
526            OpenProcess, WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION,
527            PROCESS_SYNCHRONIZE,
528        },
529    };
530
531    let handle = unsafe {
532        OpenProcess(
533            PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
534            false,
535            pid.get(),
536        )
537    };
538    let handle = match handle {
539        Ok(handle) => handle,
540        Err(error) => {
541            if error.code() == ERROR_INVALID_PARAMETER.to_hresult() {
542                return Ok(false);
543            }
544            return Err(io::Error::other(error.to_string()));
545        }
546    };
547    let wait = unsafe { WaitForSingleObject(handle, 0) };
548    let close = unsafe { CloseHandle(handle) };
549    close.map_err(|error| io::Error::other(error.to_string()))?;
550    match wait.0 {
551        0 => Ok(false),
552        value if value == WAIT_TIMEOUT.0 => Ok(true),
553        _ => Err(io::Error::last_os_error()),
554    }
555}
556
557#[cfg(unix)]
558fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
559    use nix::{errno::Errno, sys::signal::kill, unistd::Pid};
560
561    let pid = i32::try_from(pid.get())
562        .map(Pid::from_raw)
563        .map_err(io::Error::other)?;
564    match kill(pid, None) {
565        Ok(()) | Err(Errno::EPERM) => Ok(true),
566        Err(Errno::ESRCH) => Ok(false),
567        Err(error) => Err(io::Error::from(error)),
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    #[test]
574    fn owned_commands_hide_their_console_window_by_default() {
575        assert!(super::OwnedCommand::new("x").windows_hidden);
576        assert!(super::OwnedCommand::from_command(std::process::Command::new("x")).windows_hidden);
577    }
578
579    /// A kill-on-close job standing in for an in-process host's own job object.
580    #[cfg(windows)]
581    fn host_job() -> std::os::windows::io::OwnedHandle {
582        use std::os::windows::io::FromRawHandle;
583        use windows::Win32::System::JobObjects::{
584            CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
585            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
586        };
587        let job = unsafe { CreateJobObjectW(None, None) }.unwrap();
588        let job_handle = unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(job.0 as _) };
589        let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
590        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
591        unsafe {
592            SetInformationJobObject(
593                job,
594                JobObjectExtendedLimitInformation,
595                &info as *const _ as *const _,
596                std::mem::size_of_val(&info) as u32,
597            )
598        }
599        .unwrap();
600        job_handle
601    }
602
603    #[cfg(windows)]
604    fn in_job(pid: u32, job: &std::os::windows::io::OwnedHandle) -> bool {
605        use std::os::windows::io::AsRawHandle;
606        #[link(name = "kernel32")]
607        unsafe extern "system" {
608            fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut std::ffi::c_void;
609            fn IsProcessInJob(
610                process: *mut std::ffi::c_void,
611                job: *mut std::ffi::c_void,
612                result: *mut i32,
613            ) -> i32;
614            fn CloseHandle(handle: *mut std::ffi::c_void) -> i32;
615        }
616        unsafe {
617            let process = OpenProcess(0x1000, 0, pid); // PROCESS_QUERY_LIMITED_INFORMATION
618            assert!(!process.is_null());
619            let mut inside = 0;
620            assert_ne!(
621                IsProcessInJob(process, job.as_raw_handle() as _, &mut inside),
622                0
623            );
624            CloseHandle(process);
625            inside != 0
626        }
627    }
628
629    #[cfg(windows)]
630    #[test]
631    fn children_join_the_callers_job_and_die_when_the_caller_closes_it() {
632        use std::os::windows::io::AsHandle;
633        use std::time::{Duration, Instant};
634        let job = host_job();
635        let sleeper = || {
636            let mut command = super::OwnedCommand::new("powershell.exe");
637            command.command_mut().args([
638                "-NoLogo",
639                "-NoProfile",
640                "-NonInteractive",
641                "-Command",
642                "Start-Sleep -Seconds 60",
643            ]);
644            command.windows_job(job.as_handle()).unwrap();
645            command.spawn().unwrap()
646        };
647        // The second child joins a job that already holds a process: binding must happen
648        // before RightKit's own jobs, or Windows refuses it.
649        let (first, second) = (sleeper(), sleeper());
650        let pids = [first.id(), second.id()];
651        assert!(
652            pids.iter().all(|pid| in_job(*pid, &job)),
653            "children must be in the caller's job"
654        );
655
656        // The caller closes its job while the owned children are still alive: they die.
657        drop(job);
658        let deadline = Instant::now() + Duration::from_secs(5);
659        for pid in pids {
660            let process = super::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
661            while process.is_running().unwrap_or(false) {
662                assert!(
663                    Instant::now() < deadline,
664                    "child {pid} survived its caller's job"
665                );
666                std::thread::sleep(Duration::from_millis(20));
667            }
668        }
669        drop((first, second));
670    }
671
672    #[test]
673    fn a_failure_after_spawn_cleans_up_the_partially_wrapped_child() {
674        use std::{
675            process::Command,
676            sync::{
677                atomic::{AtomicU32, Ordering},
678                Arc,
679            },
680            thread,
681            time::{Duration, Instant},
682        };
683
684        // A long-lived child that survives on its own if the partial-spawn cleanup fails.
685        let command = if cfg!(windows) {
686            // windowless: spawned through spawn_owned (CREATE_NO_WINDOW).
687            let mut command = Command::new("powershell.exe");
688            command.args([
689                "-NoLogo",
690                "-NoProfile",
691                "-NonInteractive",
692                "-Command",
693                "Start-Sleep -Seconds 60",
694            ]);
695            command
696        } else {
697            // windowless: spawned through spawn_owned (CREATE_NO_WINDOW).
698            let mut command = Command::new("sleep");
699            command.arg("60");
700            command
701        };
702        let spawned_pid = Arc::new(AtomicU32::new(0));
703
704        assert!(super::spawn_for_test(command, Arc::clone(&spawned_pid)).is_err());
705        let pid = spawned_pid.load(Ordering::SeqCst);
706        assert_ne!(pid, 0, "test must fail after the OS process was spawned");
707        let process = super::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
708        let deadline = Instant::now() + Duration::from_secs(5);
709        while process.is_running().unwrap_or(false) {
710            assert!(
711                Instant::now() < deadline,
712                "partially spawned process leaked"
713            );
714            thread::sleep(Duration::from_millis(20));
715        }
716    }
717}