Skip to main content

rightkit_child_process/
owned.rs

1use crate::process_handle::{lock_child, ProcessHandle};
2#[cfg(unix)]
3use crate::unix_child::{LeaderState, SignalOutcome, SignalTarget};
4use std::{
5    ffi::OsStr,
6    io,
7    num::NonZeroU32,
8    process::{ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus},
9    sync::{Arc, Mutex, MutexGuard},
10    thread,
11    time::{Duration, Instant, SystemTime},
12};
13#[cfg(unix)]
14type PlatformChild = crate::unix_child::UnixChild;
15#[cfg(windows)]
16type PlatformChild = crate::spawn_windows::WindowsChild;
17
18#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
19pub enum JobAssignmentMode {
20    #[default]
21    Strict,
22    BestEffort,
23}
24
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
26pub enum UnixContainment {
27    #[default]
28    ProcessGroup,
29    Session,
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct TerminationOptions {
34    /// Send TERM immediately on Unix, then KILL after this tree-wide grace.
35    pub grace: Duration,
36    /// Passed to TerminateJobObject/TerminateProcess on Windows.
37    pub windows_exit_code: u32,
38}
39impl Default for TerminationOptions {
40    fn default() -> Self {
41        Self {
42            grace: Duration::ZERO,
43            windows_exit_code: 1,
44        }
45    }
46}
47impl TerminationOptions {
48    pub fn with_grace(mut self, grace: Duration) -> Self {
49        self.grace = grace;
50        self
51    }
52    pub fn with_windows_exit_code(mut self, code: u32) -> Self {
53        self.windows_exit_code = code;
54        self
55    }
56}
57
58/// Every failed assignment in a best-effort Windows spawn is retained.
59#[derive(Debug)]
60pub struct JobAssignmentFailure {
61    pub stage: &'static str,
62    pub error: io::Error,
63}
64impl JobAssignmentFailure {
65    pub fn raw_os_error(&self) -> Option<i32> {
66        self.error.raw_os_error()
67    }
68}
69
70/// Borrowed OS handle/fd value; keep it valid until spawn returns.
71#[cfg(windows)]
72pub type RawHandleOrFd = std::os::windows::io::RawHandle;
73#[cfg(unix)]
74pub type RawHandleOrFd = std::os::fd::RawFd;
75#[cfg(windows)]
76type InheritedValue = usize;
77#[cfg(unix)]
78type InheritedValue = RawHandleOrFd;
79
80#[derive(Debug)]
81pub struct OwnedCommand {
82    command: Command,
83    windows_hidden: bool,
84    caller_job: CallerJob,
85    job_assignment: JobAssignmentMode,
86    unix_containment: UnixContainment,
87    kill_on_parent_exit: bool,
88    // None preserves the existing unbounded partial-spawn reap.
89    partial_spawn_cleanup_timeout: Option<Duration>,
90    inherited_handles: Option<Vec<InheritedValue>>,
91    #[cfg(windows)]
92    windows_spawn_config: Option<crate::WindowsSpawnConfig>,
93    #[cfg(windows)]
94    windows_spawn_config_auto: bool,
95    env_cleared: bool,
96}
97#[cfg(windows)]
98type CallerJob = Option<std::os::windows::io::OwnedHandle>;
99#[cfg(not(windows))]
100type CallerJob = Option<std::convert::Infallible>;
101
102impl OwnedCommand {
103    pub fn new(program: impl AsRef<OsStr>) -> Self {
104        let command = Self::from_command(Command::new(program));
105        #[cfg(windows)]
106        let command = {
107            let mut command = command;
108            // Only a fresh Command has known inherited stdio/environment defaults. The
109            // config is marked auto, and `command_mut` clears it.
110            command.windows_spawn_config = Some(crate::WindowsSpawnConfig::default());
111            command.windows_spawn_config_auto = true;
112            command
113        };
114        command
115    }
116    /// GUI/service children are hidden on Windows by default. A wrapped `Command` may carry
117    /// stdio, environment and raw-argument settings this crate cannot read back, so on
118    /// Windows an allowlisted spawn (`inherit_handles_only`) from `from_command` requires an
119    /// explicit `windows_spawn_config`. The crate never widens what a child inherits by
120    /// default.
121    pub fn from_command(command: Command) -> Self {
122        Self {
123            command,
124            windows_hidden: true,
125            caller_job: None,
126            job_assignment: JobAssignmentMode::Strict,
127            unix_containment: UnixContainment::ProcessGroup,
128            kill_on_parent_exit: false,
129            partial_spawn_cleanup_timeout: None,
130            inherited_handles: None,
131            #[cfg(windows)]
132            windows_spawn_config: None,
133            #[cfg(windows)]
134            windows_spawn_config_auto: false,
135            env_cleared: false,
136        }
137    }
138    pub fn command_mut(&mut self) -> &mut Command {
139        #[cfg(windows)]
140        if self.windows_spawn_config_auto {
141            // A mutable std Command can change settings with no stable getter.
142            self.windows_spawn_config = None;
143        }
144        &mut self.command
145    }
146    /// Inherit only these handles/fds plus configured stdio; values stay caller-owned.
147    pub fn inherit_handles_only(
148        &mut self,
149        handles: impl IntoIterator<Item = RawHandleOrFd>,
150    ) -> &mut Self {
151        #[cfg(windows)]
152        let handles = handles.into_iter().map(|handle| handle as usize).collect();
153        #[cfg(unix)]
154        let handles = handles.into_iter().collect();
155        self.inherited_handles = Some(handles);
156        self
157    }
158    /// Supply opaque stdio/environment/raw-arg settings for native allowlisted spawns.
159    /// Required after from_command/command_mut when inherit_handles_only is set.
160    /// Ordinary std spawns ignore it; fresh new() Commands have known defaults.
161    #[cfg(windows)]
162    pub fn windows_spawn_config(&mut self, config: crate::WindowsSpawnConfig) -> &mut Self {
163        self.windows_spawn_config = Some(config);
164        self.windows_spawn_config_auto = false;
165        self
166    }
167    /// Clear environment, then copy only named variables set in this process.
168    pub fn env_allowlist<I, S>(&mut self, keys: I) -> &mut Self
169    where
170        I: IntoIterator<Item = S>,
171        S: AsRef<OsStr>,
172    {
173        let kept: Vec<_> = keys
174            .into_iter()
175            .filter_map(|k| std::env::var_os(k.as_ref()).map(|v| (k.as_ref().to_os_string(), v)))
176            .collect();
177        self.command.env_clear();
178        self.env_cleared = true;
179        for (k, v) in kept {
180            self.command.env(k, v);
181        }
182        self
183    }
184    /// [`Self::env_allowlist`] over [`DEFAULT_ENV_ALLOWLIST`] plus `extra` (a consumer's own
185    /// variables, for instance a model-cache location). Call it once: like `env_allowlist` it
186    /// starts from an empty environment, so a second call drops what the first kept.
187    pub fn env_allowlist_with_defaults<I, S>(&mut self, extra: I) -> &mut Self
188    where
189        I: IntoIterator<Item = S>,
190        S: AsRef<OsStr>,
191    {
192        let keys: Vec<std::ffi::OsString> = DEFAULT_ENV_ALLOWLIST
193            .iter()
194            .map(std::ffi::OsString::from)
195            .chain(extra.into_iter().map(|k| k.as_ref().to_os_string()))
196            .collect();
197        self.env_allowlist(keys)
198    }
199    /// True once [`Self::env_allowlist`] (or `env_allowlist_with_defaults`) cleared the inherited
200    /// environment on this command. A `Command` that was cleared with `env_clear` directly, or
201    /// wrapped by `from_command` after it was cleared, reads `false`: the state is not observable
202    /// from `std::process::Command`.
203    pub fn env_is_cleared(&self) -> bool {
204        self.env_cleared
205    }
206    pub fn env_strip<I, S>(&mut self, keys: I) -> &mut Self
207    where
208        I: IntoIterator<Item = S>,
209        S: AsRef<OsStr>,
210    {
211        for k in keys {
212            self.command.env_remove(k);
213        }
214        self
215    }
216    /// Compatibility no-op: hidden is already the default.
217    pub fn windows_hide(&mut self) -> &mut Self {
218        self.windows_hidden = true;
219        self
220    }
221    /// Duplicate caller job; assign before the empty nested RightKit job.
222    #[cfg(windows)]
223    pub fn windows_job(
224        &mut self,
225        job: std::os::windows::io::BorrowedHandle<'_>,
226    ) -> io::Result<&mut Self> {
227        self.caller_job = Some(job.try_clone_to_owned()?);
228        Ok(self)
229    }
230    /// Ignored on Unix. BestEffort reports errors on OwnedChild.
231    pub fn job_assignment_mode(&mut self, mode: JobAssignmentMode) -> &mut Self {
232        self.job_assignment = mode;
233        self
234    }
235    /// Ignored on Windows. Session runs setsid before exec.
236    pub fn unix_containment(&mut self, mode: UnixContainment) -> &mut Self {
237        self.unix_containment = mode;
238        self
239    }
240    /// Opt-in, off by default. Linux only: the kernel sends `SIGTERM` to the direct child
241    /// (`PR_SET_PDEATHSIG`) when the thread that spawned it exits, which includes this
242    /// process dying without running cleanup (for example SIGKILL). The signal reaches the
243    /// direct child only, never the rest of its group, it can be caught, and there is no
244    /// `SIGKILL` escalation. It is keyed to the spawning *thread*, so spawn from a thread
245    /// that lives as long as the child.
246    ///
247    /// Other Unix hosts, macOS included, have no parent-death primitive this crate relies on:
248    /// the spawn fails with `ErrorKind::Unsupported` before anything is started, rather than
249    /// run the child untied. Windows ignores the option because kill-on-close job objects
250    /// already cover parent death. Rejected (`InvalidInput`) for detached spawns.
251    pub fn kill_on_parent_exit(&mut self, enabled: bool) -> &mut Self {
252        self.kill_on_parent_exit = enabled;
253        self
254    }
255    /// Bound failed-spawn cleanup. Timed-out children go to a background reaper.
256    pub fn partial_spawn_cleanup_timeout(&mut self, timeout: Duration) -> &mut Self {
257        self.partial_spawn_cleanup_timeout = Some(timeout);
258        self
259    }
260    pub fn spawn(self) -> io::Result<OwnedChild> {
261        self.spawn_inner(false, false, None)
262    }
263    /// Shared launch with no owner job or terminate-on-drop. Unix uses setsid;
264    /// Windows requires breakaway and never falls back when escape is denied.
265    /// Stdio follows Command: dropping piped stdin can still deliver EOF.
266    pub fn spawn_uncontained_detached(self) -> io::Result<OwnedChild> {
267        self.spawn_inner(true, false, None)
268    }
269    fn spawn_inner(
270        mut self,
271        detached: bool,
272        fail_after_spawn: bool,
273        captured_pid: Option<Arc<std::sync::atomic::AtomicU32>>,
274    ) -> io::Result<OwnedChild> {
275        if detached && self.caller_job.is_some() {
276            return Err(io::Error::new(
277                io::ErrorKind::InvalidInput,
278                "detached launch cannot join a caller job",
279            ));
280        }
281        if detached && self.kill_on_parent_exit {
282            return Err(io::Error::new(
283                io::ErrorKind::InvalidInput,
284                "kill_on_parent_exit cannot combine with a detached launch",
285            ));
286        }
287        #[cfg(all(unix, not(target_os = "linux")))]
288        if self.kill_on_parent_exit {
289            return Err(io::Error::new(
290                io::ErrorKind::Unsupported,
291                "kill_on_parent_exit is supported on Linux only",
292            ));
293        }
294        #[cfg(unix)]
295        {
296            use std::os::unix::process::CommandExt;
297            let session = detached || self.unix_containment == UnixContainment::Session;
298            let inheritance = self
299                .inherited_handles
300                .take()
301                .map(crate::process_unix::Inheritance::new)
302                .transpose()?;
303            // The supervisor's pid, read here and compared in the child (see `die_with_parent`).
304            #[cfg(target_os = "linux")]
305            let supervisor = self
306                .kill_on_parent_exit
307                // SAFETY: getpid has no preconditions.
308                .then(|| unsafe { libc::getpid() });
309            // Only async-signal-safe libc calls run between fork and exec.
310            unsafe {
311                self.command.pre_exec(move || {
312                    let result = if session {
313                        libc::setsid()
314                    } else {
315                        libc::setpgid(0, 0)
316                    };
317                    if result < 0 {
318                        return Err(io::Error::last_os_error());
319                    }
320                    if let Some(inheritance) = &inheritance {
321                        inheritance.apply()?;
322                    }
323                    #[cfg(target_os = "linux")]
324                    if let Some(supervisor) = supervisor {
325                        crate::process_unix::die_with_parent(supervisor)?;
326                    }
327                    Ok(())
328                });
329            }
330        }
331        #[cfg(windows)]
332        crate::process_windows::configure(&mut self.command, detached, self.windows_hidden);
333        #[cfg(unix)]
334        let _ = (self.windows_hidden, self.job_assignment);
335        #[cfg(windows)]
336        let _ = (self.unix_containment, self.kill_on_parent_exit);
337        let spawned_at = SystemTime::now();
338        #[cfg(unix)]
339        let child = {
340            let mut child = crate::unix_child::UnixChild::new(self.command.spawn()?);
341            // A child whose pid cannot be tied to it is stopped here, while the pid is
342            // certainly still its own: no later signal could be proven to reach it.
343            child.require_identity()?;
344            child
345        };
346        #[cfg(windows)]
347        let child: PlatformChild = match self.inherited_handles.as_deref() {
348            None => {
349                // An ordinary spawn inherits every inheritable handle. Held so it never runs
350                // while an allowlisted spawn has private handles raised.
351                let _spawning = crate::spawn_windows::spawn_guard();
352                self.command.spawn()?.into()
353            }
354            Some(handles) => {
355                let config = self.windows_spawn_config.take().ok_or_else(|| {
356                    io::Error::new(io::ErrorKind::InvalidInput,
357                        "allowlisted Windows spawn requires windows_spawn_config for opaque Command settings")
358                })?;
359                crate::spawn_windows::spawn(
360                    &self.command,
361                    config,
362                    handles,
363                    detached,
364                    self.windows_hidden,
365                    self.env_cleared,
366                    self.partial_spawn_cleanup_timeout,
367                )?
368            }
369        };
370        let pid = child.id();
371        if let Some(captured) = captured_pid {
372            captured.store(pid, std::sync::atomic::Ordering::SeqCst);
373        }
374        let child = Arc::new(Mutex::new(child));
375        let handle = match ProcessHandle::new(&child, spawned_at) {
376            Ok(handle) => handle,
377            Err(error) => {
378                cleanup_direct(child, self.partial_spawn_cleanup_timeout);
379                return Err(error);
380            }
381        };
382        let mut partial = PartialChild {
383            child: Some(OwnedChild {
384                child,
385                handle,
386                pid,
387                detached,
388                terminate_on_drop: !detached,
389                drop_reap_timeout: DEFAULT_DROP_REAP_TIMEOUT,
390                tree_terminated: false,
391                assignment_failures: Vec::new(),
392                #[cfg(windows)]
393                job: None,
394            }),
395            timeout: self.partial_spawn_cleanup_timeout,
396        };
397        #[cfg(windows)]
398        if !detached {
399            let owned = partial.child.as_mut().expect("partial child is present");
400            crate::process_windows::assign_jobs(
401                &owned.handle,
402                self.caller_job.take(),
403                self.job_assignment,
404                &mut owned.job,
405                &mut owned.assignment_failures,
406            )?;
407        }
408        #[cfg(windows)]
409        partial
410            .child
411            .as_ref()
412            .expect("partial child is present")
413            .child()
414            .resume(detached)?;
415        if fail_after_spawn {
416            return Err(io::Error::other("injected post-spawn wrapping failure"));
417        }
418        Ok(partial.child.take().expect("partial child is present"))
419    }
420}
421
422/// How long `Drop` waits for a killed leader to be reaped before handing the reap to a
423/// background thread.
424pub const DEFAULT_DROP_REAP_TIMEOUT: Duration = Duration::from_secs(2);
425
426pub const DEFAULT_ENV_ALLOWLIST: &[&str] = &[
427    "PATH",
428    "HOME",
429    "USER",
430    "LOGNAME",
431    "LANG",
432    "LC_ALL",
433    "TMPDIR",
434    "TEMP",
435    "TMP",
436    "SystemRoot",
437    "SYSTEMROOT",
438    "USERPROFILE",
439    "APPDATA",
440    "LOCALAPPDATA",
441    "ProgramData",
442    "COMSPEC",
443    "PATHEXT",
444    "XDG_DATA_HOME",
445    "XDG_RUNTIME_DIR",
446];
447
448#[derive(Debug)]
449pub struct OwnedChild {
450    child: Arc<Mutex<PlatformChild>>,
451    handle: ProcessHandle,
452    // Captured before any wait can reap the direct child.
453    pid: u32,
454    detached: bool,
455    terminate_on_drop: bool,
456    drop_reap_timeout: Duration,
457    tree_terminated: bool,
458    assignment_failures: Vec<JobAssignmentFailure>,
459    #[cfg(windows)]
460    job: Option<crate::process_windows::KillOnCloseJob>,
461}
462#[derive(Clone, Copy, Debug, Eq, PartialEq)]
463pub enum ReapOutcome {
464    Exited(ExitStatus),
465    TimedOut,
466}
467#[derive(Clone, Copy, Debug, Eq, PartialEq)]
468pub enum WaitOutcome {
469    Exited(ExitStatus),
470    Terminated(ExitStatus),
471}
472
473impl OwnedChild {
474    pub fn id(&self) -> u32 {
475        self.pid
476    }
477    /// Borrow child state, including stdio, wait and try_wait. Windows uses a
478    /// native view with std pipe types for both spawn paths; Unix uses `UnixChild`, which
479    /// has the same members and does not expose the std `Child`. Release this guard
480    /// before another accessor or exit reader; it holds the shared reap lock.
481    ///
482    /// Unix: a `wait` or `try_wait` through the guard that reaps the leader permanently
483    /// ends process-group signalling for this child, exactly like `OwnedChild::wait`.
484    ///
485    /// Unix: `wait` through the guard holds the shared lock until the child has exited, because
486    /// the guard is the lock. A bounded wait on a `ProcessHandle` clone, `kill`,
487    /// `terminate_tree` and `Drop` on other threads queue behind it and can exceed their own
488    /// deadlines, as with `std::process::Child::wait` through this accessor in 0.3.3. Wait
489    /// through `OwnedChild::wait`/`wait_timeout` or a `ProcessHandle` when anything else must
490    /// stay live meanwhile. (Windows: `WindowsChild::wait` through the guard blocks in
491    /// `WaitForSingleObject` while holding the lock, as in 0.3.3.)
492    pub fn child(&self) -> MutexGuard<'_, PlatformChild> {
493        lock_child(&self.child)
494    }
495    /// Runs `act` with the leader's pid while the leader is proven to be this child and
496    /// unreaped, holding the shared lock so no reap can fall between the proof and `act`.
497    /// Returns `Ok(None)` without calling `act` once the leader has been reaped or its pid no
498    /// longer provably names it. For code that must signal by pid (a descendant sweep) and
499    /// would otherwise act on a number that may have been recycled. The lock is held for as
500    /// long as `act` runs (that is what keeps a reap out), so every other thread's wait,
501    /// including a bounded one, is delayed by that long: keep `act` short, and never call back
502    /// into this child from it. Windows has no recyclable-pid hazard here and always runs it.
503    pub fn while_unreaped<R>(&self, act: impl FnOnce(u32) -> R) -> io::Result<Option<R>> {
504        #[cfg(unix)]
505        {
506            let mut child = lock_child(&self.child);
507            if child.leader_state()? == LeaderState::Reaped {
508                return Ok(None);
509            }
510            Ok(Some(act(self.pid)))
511        }
512        #[cfg(windows)]
513        Ok(Some(act(self.pid)))
514    }
515    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
516        self.child().stdin.take()
517    }
518    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
519        self.child().stdout.take()
520    }
521    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
522        self.child().stderr.take()
523    }
524    pub fn job_assignment_failures(&self) -> &[JobAssignmentFailure] {
525        &self.assignment_failures
526    }
527    /// An independent exit reader. A monitor thread that only needs to learn that the leader
528    /// exited should call [`ProcessHandle::wait_exited`], which does not reap; `wait` reaps, and
529    /// on Unix that ends the owner's ability to signal descendants that outlive the leader.
530    pub fn process_handle(&self) -> io::Result<ProcessHandle> {
531        self.handle.try_clone()
532    }
533    pub fn creation_time(&self) -> Option<SystemTime> {
534        self.handle.creation_time()
535    }
536    #[cfg(windows)]
537    pub fn creation_time_ticks(&self) -> Option<u64> {
538        self.handle.creation_time_ticks()
539    }
540    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
541        // Through the handle so the reap also seals the process group's state.
542        self.handle.try_wait()
543    }
544    // Preserve std Child::wait's stdin-close behavior. ProcessHandle performs
545    // native blocking waits & keeps Unix reap results in std's shared cache.
546    pub fn wait(&mut self) -> io::Result<ExitStatus> {
547        drop(self.child().stdin.take());
548        self.handle.wait()
549    }
550    pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result<Option<ExitStatus>> {
551        self.handle.wait_timeout(timeout)
552    }
553    /// Asks the tree to stop, waits up to `grace` for the leader, then forces the tree down.
554    ///
555    /// Unix: TERM goes to the group while the leader is still unreaped. If the leader exits
556    /// within `grace` the result is `Exited`, but descendants that outlived it (or ignored
557    /// TERM) are killed before the leader is reaped, while it still pins the group id. If it
558    /// does not exit in time the whole group is killed and the result is `Terminated`.
559    ///
560    /// Windows: if the leader exits within `grace` the result is `Exited` with its status, but
561    /// the retained job is terminated first, so descendants that outlived it do not survive.
562    pub fn wait_or_kill(&mut self, grace: Duration) -> io::Result<WaitOutcome> {
563        #[cfg(unix)]
564        {
565            if !self.tree_terminated {
566                self.signal(libc::SIGTERM)?;
567            }
568            // Looks without reaping until the leader has exited or the grace has passed.
569            if self.handle.wait_exited_timeout(grace)? {
570                // Exited, not reaped yet: the zombie still pins the group id, so the
571                // stragglers can be killed safely, and only then reaped. (If a handle clone
572                // reaped it, the signal is a no-op, the cached status is returned and group
573                // signalling is over.)
574                self.force_kill(1)?;
575                return self.wait().map(WaitOutcome::Exited);
576            }
577            self.terminate_tree().map(WaitOutcome::Terminated)
578        }
579        #[cfg(windows)]
580        {
581            if let Some(status) = self.wait_timeout(grace)? {
582                // The leader left, but descendants may still be in the retained job (and hold
583                // the pipes). Bring the job down before reporting; the leader's own status,
584                // already collected, is what the caller gets.
585                self.force_kill(1)?;
586                return Ok(WaitOutcome::Exited(status));
587            }
588            self.terminate_tree().map(WaitOutcome::Terminated)
589        }
590    }
591    /// Immediate forced termination, preserving the existing default.
592    /// Windows: the retained job is signalled even after the parent was reaped.
593    /// Unix: the group is signalled only while the leader is unreaped (see the type
594    /// documentation); once any path has reaped it, only the reap result is returned.
595    pub fn terminate_tree(&mut self) -> io::Result<ExitStatus> {
596        self.force_kill(1)?;
597        self.wait()
598    }
599    /// Unix: TERM now, tree-wide grace, then KILL, all while the leader is unreaped (an
600    /// exited leader stays unreaped until this call reaps it, so descendants still receive
601    /// the KILL). If another path already reaped the leader, nothing is signalled.
602    /// Windows: immediately terminate retained job with supplied exit code.
603    pub fn terminate_tree_with_options(
604        &mut self,
605        options: TerminationOptions,
606    ) -> io::Result<ExitStatus> {
607        self.start_termination(options)?;
608        self.wait()
609    }
610    pub fn terminate_tree_bounded(&mut self, timeout: Duration) -> io::Result<ReapOutcome> {
611        self.terminate_tree_bounded_with_options(timeout, TerminationOptions::default())
612    }
613    /// timeout covers grace plus reap; grace is clipped to timeout.
614    pub fn terminate_tree_bounded_with_options(
615        &mut self,
616        timeout: Duration,
617        mut options: TerminationOptions,
618    ) -> io::Result<ReapOutcome> {
619        let started = Instant::now();
620        options.grace = options.grace.min(timeout);
621        self.start_termination(options)?;
622        Ok(
623            match self.wait_timeout(timeout.saturating_sub(started.elapsed()))? {
624                Some(status) => ReapOutcome::Exited(status),
625                None => ReapOutcome::TimedOut,
626            },
627        )
628    }
629    fn start_termination(&mut self, options: TerminationOptions) -> io::Result<()> {
630        #[cfg(unix)]
631        if !self.tree_terminated {
632            self.signal(libc::SIGTERM)?;
633            // Stops waiting as soon as nothing but the exited, unreaped leader is left (or the
634            // leader was reaped elsewhere, which ends signalling). The leader is not reaped
635            // here: it must keep pinning the group id for the KILL below.
636            self.await_tree(options.grace)?;
637        }
638        self.force_kill(options.windows_exit_code)
639    }
640    /// Waits, for at most `grace`, until the leader has exited and every other member of its
641    /// group has too. The leader is observed without reaping it; then the group is listed
642    /// every [`POLL_INTERVAL`](crate::exit_watch::POLL_INTERVAL) (the published 0.3.3 interval)
643    /// until nothing but the leader is left. A group that cannot be listed is waited out for
644    /// the rest of the grace in one timed sleep. Each listing takes the child lock briefly and
645    /// releases it before the thread sleeps. Bounded by `grace` in every case.
646    #[cfg(unix)]
647    fn await_tree(&self, grace: Duration) -> io::Result<()> {
648        use crate::{exit_watch::POLL_INTERVAL, unix_child::Members};
649        let Some(deadline) = Instant::now().checked_add(grace) else {
650            return Ok(());
651        };
652        let left = |deadline: Instant| deadline.saturating_duration_since(Instant::now());
653        if !self.handle.wait_exited_timeout(left(deadline))? {
654            return Ok(()); // the leader outlived the grace
655        }
656        if self.detached {
657            return Ok(()); // a detached child owns only its direct pid
658        }
659        loop {
660            match lock_child(&self.child).group_other_members()? {
661                Members::Pids(pids) if pids.is_empty() => return Ok(()),
662                Members::Pids(_) => {}
663                Members::Unknown => return wait_out(left(deadline)),
664            }
665            let remaining = left(deadline);
666            if remaining.is_zero() {
667                return Ok(()); // the grace is over
668            }
669            thread::sleep(POLL_INTERVAL.min(remaining));
670        }
671    }
672    /// Sends `signal` to the group (or, for a detached child, to the leader alone) while the
673    /// leader is unreaped and proven. Takes the child lock for each attempt and releases it
674    /// between attempts.
675    #[cfg(unix)]
676    fn signal(&self, signal: i32) -> io::Result<()> {
677        // Detached children only own their direct pid. Under the child's lock, and only while
678        // the leader is unreaped, so the id cannot have been recycled.
679        let target = if self.detached {
680            SignalTarget::Leader
681        } else {
682            SignalTarget::Group
683        };
684        let started = Instant::now();
685        loop {
686            let (outcome, watch) = {
687                let mut child = lock_child(&self.child);
688                (child.signal(target, signal)?, child.exit_watch())
689            };
690            if outcome != SignalOutcome::MidExit {
691                return Ok(());
692            }
693            // macOS refused the group signal while its last members are mid-exit. The group is
694            // gone once the exit completes: look again, for a bounded time, without the lock.
695            let remaining = MID_EXIT_RETRY.saturating_sub(started.elapsed());
696            if remaining.is_zero() {
697                return Err(io::Error::from_raw_os_error(libc::EPERM));
698            }
699            watch.pause(remaining);
700        }
701    }
702    fn force_kill(&mut self, code: u32) -> io::Result<()> {
703        if self.tree_terminated {
704            return Ok(());
705        }
706        #[cfg(unix)]
707        {
708            let _ = code;
709            self.signal(libc::SIGKILL)?;
710        }
711        #[cfg(windows)]
712        crate::process_windows::terminate(&self.handle, self.job.as_ref(), code)?;
713        self.tree_terminated = true;
714        Ok(())
715    }
716}
717impl OwnedChild {
718    /// How long dropping this child waits for the killed leader to be reaped (default
719    /// [`DEFAULT_DROP_REAP_TIMEOUT`]). `Drop` sends the kill at once and never waits longer than
720    /// this: a leader that has not been reaped by then (it is in an uninterruptible state, for
721    /// instance) is reaped later by a background thread on Unix, and on Windows the process
722    /// handle is closed. `Duration::ZERO` means "do not wait". Does not affect
723    /// [`terminate_tree`](Self::terminate_tree), which still waits for the reap.
724    pub fn set_drop_reap_timeout(&mut self, timeout: Duration) -> &mut Self {
725        self.drop_reap_timeout = timeout;
726        self
727    }
728    /// The bound `Drop` applies to the reap.
729    pub fn drop_reap_timeout(&self) -> Duration {
730        self.drop_reap_timeout
731    }
732    /// Reaps the leader on a thread of its own, so an unbounded wait never blocks the caller.
733    fn reap_in_background(&self) {
734        #[cfg(unix)]
735        if let Ok(reader) = self.handle.try_clone() {
736            let _ = thread::Builder::new()
737                .name("rightkit-drop-reap".into())
738                .spawn(move || {
739                    let _ = reader.wait();
740                });
741        }
742    }
743    /// Identities of this child's descendants (see [`crate::AdoptedIdentity::descendants`]), taken
744    /// while the leader is proven to be this child. Empty once the leader has been reaped:
745    /// its children are re-parented then and can no longer be found by their parent.
746    pub fn descendant_identities(&self) -> io::Result<Vec<crate::AdoptedIdentity>> {
747        let root = self.while_unreaped(|pid| match NonZeroU32::new(pid) {
748            Some(pid) => crate::AdoptedIdentity::capture(pid),
749            None => Ok(None),
750        })?;
751        match root {
752            Some(Ok(Some(root))) => root.descendants(),
753            Some(Err(error)) => Err(error),
754            _ => Ok(Vec::new()),
755        }
756    }
757}
758impl Drop for OwnedChild {
759    fn drop(&mut self) {
760        if self.terminate_on_drop {
761            // Bounded: the kill is sent at once, the reap is awaited for at most
762            // `drop_reap_timeout`, and a child that has not been reaped by then is reaped by
763            // a background thread. Dropping never waits on a process that will not die.
764            if self.force_kill(1).is_ok() {
765                drop(self.child().stdin.take());
766                if !matches!(
767                    self.handle.wait_timeout(self.drop_reap_timeout),
768                    Ok(Some(_))
769                ) {
770                    self.reap_in_background();
771                }
772            }
773        } else if self.detached {
774            #[cfg(unix)]
775            {
776                // Shared launch still needs eventual reaping. Close owner pipes
777                // now; the reaper thread makes non-blocking looks and releases the child
778                // lock between them, so exit readers and kills are not held behind it.
779                {
780                    let mut child = self.child();
781                    drop(child.stdin.take());
782                    drop(child.stdout.take());
783                    drop(child.stderr.take());
784                }
785                if let Ok(reader) = self.handle.try_clone() {
786                    let _ = thread::Builder::new()
787                        .name("rightkit-detached-reap".into())
788                        .spawn(move || {
789                            let _ = reader.wait();
790                        });
791                }
792            }
793        }
794    }
795}
796
797struct PartialChild {
798    child: Option<OwnedChild>,
799    timeout: Option<Duration>,
800}
801impl Drop for PartialChild {
802    fn drop(&mut self) {
803        if let Some(mut child) = self.child.take() {
804            // Disable unbounded normal Drop before bounded cleanup.
805            child.terminate_on_drop = false;
806            let _ = child.force_kill(1);
807            cleanup_direct(Arc::clone(&child.child), self.timeout);
808        }
809    }
810}
811/// How long a group signal that macOS refused with `EPERM` (the last members are mid-exit) is
812/// retried before the refusal is reported.
813#[cfg(unix)]
814const MID_EXIT_RETRY: Duration = Duration::from_millis(300);
815
816/// The rest of a grace for a group that cannot be listed. There is nothing to look at, so this
817/// is one timed sleep, not a loop.
818#[cfg(unix)]
819fn wait_out(remaining: Duration) -> io::Result<()> {
820    thread::sleep(remaining);
821    Ok(())
822}
823/// Stops and reaps a child whose spawn failed after the process existed. Every caller passes
824/// an `Arc` that nothing but the spawning thread holds (the partial `OwnedChild`, from which no
825/// `ProcessHandle` was cloned and which no caller has seen, or a child that was never wrapped),
826/// so no other thread can contend for its lock. That is why the two blocking-under-lock waits
827/// below, the unbounded one and the `rightkit-partial-reap` thread, cannot hold a deadline-
828/// bearing caller back; both are what 0.3.3 did with `std::process::Child::wait`.
829pub(crate) fn cleanup_direct(child: Arc<Mutex<PlatformChild>>, timeout: Option<Duration>) {
830    let _ = lock_child(&child).kill();
831    let Some(timeout) = timeout else {
832        // Unbounded by default, as before. Exclusive child: see above.
833        let _ = lock_child(&child).wait();
834        return;
835    };
836    #[cfg(unix)]
837    {
838        // Non-blocking looks, each under the lock and released at once, until the child is
839        // reaped or the timeout passes. A killed child is gone within one hint or interval.
840        let watch = lock_child(&child).exit_watch();
841        let deadline = Instant::now().checked_add(timeout);
842        if matches!(
843            watch.wait_for(deadline, || lock_child(&child).try_wait()),
844            Ok(Some(_))
845        ) {
846            return;
847        }
848    }
849    #[cfg(windows)]
850    {
851        let started = Instant::now();
852        loop {
853            match lock_child(&child).try_wait() {
854                Ok(Some(_)) => return,
855                Err(_) => break,
856                Ok(None) => {}
857            }
858            if started.elapsed() >= timeout {
859                break;
860            }
861            thread::sleep(Duration::from_millis(5).min(timeout.saturating_sub(started.elapsed())));
862        }
863    }
864    // std Child does not reap on Drop. Transfer timed-out children to a waiter. This thread
865    // holds the child's lock for the whole wait and is the only holder of the `Arc` (see
866    // above), so nothing can queue behind it.
867    let _ = thread::Builder::new()
868        .name("rightkit-partial-reap".into())
869        .spawn(move || {
870            let _ = lock_child(&child).wait();
871        });
872}
873
874#[derive(Clone, Copy, Debug, Eq, PartialEq)]
875pub struct AdoptedProcess {
876    pid: NonZeroU32,
877}
878impl AdoptedProcess {
879    pub fn new(pid: NonZeroU32) -> Self {
880        Self { pid }
881    }
882    pub fn id(&self) -> u32 {
883        self.pid.get()
884    }
885    pub fn is_running(&self) -> io::Result<bool> {
886        process_is_running(self.pid)
887    }
888    /// The identity (start time and executable) of the process behind this pid now, for
889    /// identity-checked signalling through [`AdoptedIdentity`](crate::AdoptedIdentity).
890    /// `Ok(None)` when no process has the pid. Capture it only when the pid is known to name
891    /// the intended process.
892    pub fn identity(&self) -> io::Result<Option<crate::AdoptedIdentity>> {
893        crate::AdoptedIdentity::capture(self.pid)
894    }
895}
896#[cfg(test)]
897fn spawn_for_test(
898    command: Command,
899    captured_pid: Arc<std::sync::atomic::AtomicU32>,
900) -> io::Result<OwnedChild> {
901    OwnedCommand::from_command(command).spawn_inner(false, true, Some(captured_pid))
902}
903
904#[cfg(windows)]
905fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
906    use windows::Win32::{
907        Foundation::{CloseHandle, ERROR_INVALID_PARAMETER, WAIT_TIMEOUT},
908        System::Threading::{
909            OpenProcess, WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION,
910            PROCESS_SYNCHRONIZE,
911        },
912    };
913
914    let handle = unsafe {
915        OpenProcess(
916            PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
917            false,
918            pid.get(),
919        )
920    };
921    let handle = match handle {
922        Ok(handle) => handle,
923        Err(error) => {
924            if error.code() == ERROR_INVALID_PARAMETER.to_hresult() {
925                return Ok(false);
926            }
927            return Err(io::Error::other(error.to_string()));
928        }
929    };
930    let wait = unsafe { WaitForSingleObject(handle, 0) };
931    let close = unsafe { CloseHandle(handle) };
932    close.map_err(|error| io::Error::other(error.to_string()))?;
933    match wait.0 {
934        0 => Ok(false),
935        value if value == WAIT_TIMEOUT.0 => Ok(true),
936        _ => Err(io::Error::last_os_error()),
937    }
938}
939
940#[cfg(unix)]
941fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
942    let pid = i32::try_from(pid.get()).map_err(io::Error::other)?;
943    loop {
944        // SAFETY: signal 0 performs only existence and permission checks; nothing is sent.
945        if unsafe { libc::kill(pid, 0) } == 0 {
946            return Ok(true);
947        }
948        let error = io::Error::last_os_error();
949        match error.raw_os_error() {
950            Some(libc::EINTR) => continue,
951            Some(libc::EPERM) => return Ok(true),
952            Some(libc::ESRCH) => return Ok(false),
953            _ => return Err(error),
954        }
955    }
956}
957
958#[cfg(test)]
959mod tests {
960    #[test]
961    fn option_defaults_preserve_existing_behavior() {
962        let command = super::OwnedCommand::new("unused");
963        assert_eq!(command.job_assignment, super::JobAssignmentMode::Strict);
964        assert_eq!(
965            command.unix_containment,
966            super::UnixContainment::ProcessGroup
967        );
968        assert_eq!(command.partial_spawn_cleanup_timeout, None);
969        assert_eq!(
970            super::TerminationOptions::default().grace,
971            std::time::Duration::ZERO
972        );
973        assert_eq!(super::TerminationOptions::default().windows_exit_code, 1);
974    }
975
976    #[cfg(not(windows))]
977    #[test]
978    fn new_and_from_command_share_spawn_settings() {
979        use super::OwnedCommand;
980        use std::process::Command;
981        // Debug covers every field. Off Windows nothing differs between the constructors.
982        let by_new = OwnedCommand::new("unused");
983        let by_from = OwnedCommand::from_command(Command::new("unused"));
984        assert_eq!(format!("{by_new:?}"), format!("{by_from:?}"));
985    }
986
987    /// Only `new` knows its Command's stdio and environment are std defaults. A wrapped
988    /// Command may carry settings the crate cannot read, so `from_command` must not invent a
989    /// default config: the allowlisted spawn then refuses instead of widening inheritance.
990    #[cfg(windows)]
991    #[test]
992    fn only_new_supplies_a_default_windows_spawn_config() {
993        use super::OwnedCommand;
994        use std::process::Command;
995        let by_new = OwnedCommand::new("unused");
996        let by_from = OwnedCommand::from_command(Command::new("unused"));
997        assert!(by_new.windows_spawn_config.is_some() && by_new.windows_spawn_config_auto);
998        assert!(by_from.windows_spawn_config.is_none() && !by_from.windows_spawn_config_auto);
999    }
1000
1001    #[test]
1002    fn kill_on_parent_exit_is_opt_in() {
1003        let mut command = super::OwnedCommand::new("unused");
1004        assert!(!command.kill_on_parent_exit);
1005        command.kill_on_parent_exit(true);
1006        assert!(command.kill_on_parent_exit);
1007    }
1008
1009    #[test]
1010    fn a_detached_spawn_rejects_kill_on_parent_exit() {
1011        // Rejected before anything is started, so the program never needs to exist.
1012        let mut command = super::OwnedCommand::new("unused");
1013        command.kill_on_parent_exit(true);
1014        let Err(error) = command.spawn_uncontained_detached() else {
1015            panic!("a detached spawn must not accept the parent-death tie");
1016        };
1017        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
1018    }
1019
1020    /// Hosts other than Linux have no parent-death primitive: the option fails closed, and the
1021    /// program is never started (it would create the marker file).
1022    #[cfg(all(unix, not(target_os = "linux")))]
1023    #[test]
1024    fn a_host_without_a_parent_death_primitive_refuses_before_starting_anything() {
1025        let marker =
1026            std::env::temp_dir().join(format!("rightkit-process-untied-{}", std::process::id()));
1027        let _ = std::fs::remove_file(&marker);
1028        let mut command = super::OwnedCommand::new("/usr/bin/touch");
1029        command.command_mut().arg(&marker);
1030        command.kill_on_parent_exit(true);
1031        let Err(error) = command.spawn() else {
1032            panic!("kill_on_parent_exit must not run the child untied");
1033        };
1034        assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
1035        std::thread::sleep(std::time::Duration::from_millis(200));
1036        assert!(!marker.exists(), "the program ran");
1037    }
1038
1039    #[test]
1040    fn option_builders_keep_every_requested_value() {
1041        use super::{JobAssignmentMode, OwnedCommand, TerminationOptions, UnixContainment};
1042        use std::time::Duration;
1043        let mut command = OwnedCommand::new("unused");
1044        command
1045            .job_assignment_mode(JobAssignmentMode::BestEffort)
1046            .unix_containment(UnixContainment::Session)
1047            .partial_spawn_cleanup_timeout(Duration::from_secs(5));
1048        assert_eq!(command.job_assignment, JobAssignmentMode::BestEffort);
1049        assert_eq!(command.unix_containment, UnixContainment::Session);
1050        assert_eq!(
1051            command.partial_spawn_cleanup_timeout,
1052            Some(Duration::from_secs(5))
1053        );
1054        let options = TerminationOptions::default()
1055            .with_grace(Duration::from_millis(100))
1056            .with_windows_exit_code(1067);
1057        assert_eq!(options.grace, Duration::from_millis(100));
1058        assert_eq!(options.windows_exit_code, 1067);
1059    }
1060
1061    #[test]
1062    fn zero_bound_partial_spawn_failure_still_reaps_the_child() {
1063        use std::{
1064            num::NonZeroU32,
1065            process::Command,
1066            sync::{
1067                atomic::{AtomicU32, Ordering},
1068                Arc,
1069            },
1070            thread,
1071            time::{Duration, Instant},
1072        };
1073        let command = if cfg!(windows) {
1074            let mut command = Command::new("powershell.exe");
1075            command.args([
1076                "-NoLogo",
1077                "-NoProfile",
1078                "-NonInteractive",
1079                "-Command",
1080                "Start-Sleep -Seconds 30",
1081            ]);
1082            command
1083        } else {
1084            let mut command = Command::new("/bin/sh");
1085            command.args(["-c", "exec sleep 30"]);
1086            command
1087        };
1088        let mut command = super::OwnedCommand::from_command(command);
1089        command.partial_spawn_cleanup_timeout(Duration::ZERO);
1090        let captured = Arc::new(AtomicU32::new(0));
1091        let started = Instant::now();
1092        assert!(command
1093            .spawn_inner(false, true, Some(Arc::clone(&captured)))
1094            .is_err());
1095        assert!(started.elapsed() < Duration::from_secs(3));
1096        let pid = captured.load(Ordering::SeqCst);
1097        assert_ne!(pid, 0);
1098        let process = super::AdoptedProcess::new(NonZeroU32::new(pid).unwrap());
1099        let started = Instant::now();
1100        while process.is_running().unwrap() {
1101            assert!(started.elapsed() < Duration::from_secs(5));
1102            thread::sleep(Duration::from_millis(5));
1103        }
1104    }
1105
1106    #[test]
1107    fn owned_commands_hide_their_console_window_by_default() {
1108        assert!(super::OwnedCommand::new("x").windows_hidden);
1109        assert!(super::OwnedCommand::from_command(std::process::Command::new("x")).windows_hidden);
1110    }
1111
1112    /// A kill-on-close job standing in for an in-process host's own job object.
1113    #[cfg(windows)]
1114    fn host_job() -> std::os::windows::io::OwnedHandle {
1115        use std::os::windows::io::FromRawHandle;
1116        use windows::Win32::System::JobObjects::{
1117            CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
1118            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
1119        };
1120        let job = unsafe { CreateJobObjectW(None, None) }.unwrap();
1121        let job_handle = unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(job.0 as _) };
1122        let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
1123        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1124        unsafe {
1125            SetInformationJobObject(
1126                job,
1127                JobObjectExtendedLimitInformation,
1128                &info as *const _ as *const _,
1129                std::mem::size_of_val(&info) as u32,
1130            )
1131        }
1132        .unwrap();
1133        job_handle
1134    }
1135
1136    #[cfg(windows)]
1137    fn in_job(pid: u32, job: &std::os::windows::io::OwnedHandle) -> bool {
1138        use std::os::windows::io::AsRawHandle;
1139        #[link(name = "kernel32")]
1140        unsafe extern "system" {
1141            fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut std::ffi::c_void;
1142            fn IsProcessInJob(
1143                process: *mut std::ffi::c_void,
1144                job: *mut std::ffi::c_void,
1145                result: *mut i32,
1146            ) -> i32;
1147            fn CloseHandle(handle: *mut std::ffi::c_void) -> i32;
1148        }
1149        unsafe {
1150            let process = OpenProcess(0x1000, 0, pid); // PROCESS_QUERY_LIMITED_INFORMATION
1151            assert!(!process.is_null());
1152            let mut inside = 0;
1153            assert_ne!(
1154                IsProcessInJob(process, job.as_raw_handle() as _, &mut inside),
1155                0
1156            );
1157            CloseHandle(process);
1158            inside != 0
1159        }
1160    }
1161
1162    #[cfg(windows)]
1163    #[test]
1164    fn children_join_the_callers_job_and_die_when_the_caller_closes_it() {
1165        use std::os::windows::io::AsHandle;
1166        use std::time::{Duration, Instant};
1167        let job = host_job();
1168        let sleeper = || {
1169            let mut command = super::OwnedCommand::new("powershell.exe");
1170            command.command_mut().args([
1171                "-NoLogo",
1172                "-NoProfile",
1173                "-NonInteractive",
1174                "-Command",
1175                "Start-Sleep -Seconds 60",
1176            ]);
1177            command.windows_job(job.as_handle()).unwrap();
1178            command.spawn().unwrap()
1179        };
1180        // The second child joins a job that already holds a process: binding must happen
1181        // before RightKit's own jobs, or Windows refuses it.
1182        let (first, second) = (sleeper(), sleeper());
1183        let pids = [first.id(), second.id()];
1184        assert!(
1185            pids.iter().all(|pid| in_job(*pid, &job)),
1186            "children must be in the caller's job"
1187        );
1188
1189        // The caller closes its job while the owned children are still alive: they die.
1190        drop(job);
1191        let deadline = Instant::now() + Duration::from_secs(5);
1192        for pid in pids {
1193            let process = super::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
1194            while process.is_running().unwrap_or(false) {
1195                assert!(
1196                    Instant::now() < deadline,
1197                    "child {pid} survived its caller's job"
1198                );
1199                std::thread::sleep(Duration::from_millis(20));
1200            }
1201        }
1202        drop((first, second));
1203    }
1204
1205    #[test]
1206    fn a_failure_after_spawn_cleans_up_the_partially_wrapped_child() {
1207        use std::{
1208            process::Command,
1209            sync::{
1210                atomic::{AtomicU32, Ordering},
1211                Arc,
1212            },
1213            thread,
1214            time::{Duration, Instant},
1215        };
1216
1217        // A long-lived child that survives on its own if the partial-spawn cleanup fails.
1218        let command = if cfg!(windows) {
1219            // windowless: spawned through spawn_owned (CREATE_NO_WINDOW).
1220            let mut command = Command::new("powershell.exe");
1221            command.args([
1222                "-NoLogo",
1223                "-NoProfile",
1224                "-NonInteractive",
1225                "-Command",
1226                "Start-Sleep -Seconds 60",
1227            ]);
1228            command
1229        } else {
1230            // windowless: spawned through spawn_owned (CREATE_NO_WINDOW).
1231            let mut command = Command::new("sleep");
1232            command.arg("60");
1233            command
1234        };
1235        let spawned_pid = Arc::new(AtomicU32::new(0));
1236
1237        assert!(super::spawn_for_test(command, Arc::clone(&spawned_pid)).is_err());
1238        let pid = spawned_pid.load(Ordering::SeqCst);
1239        assert_ne!(pid, 0, "test must fail after the OS process was spawned");
1240        let process = super::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
1241        let deadline = Instant::now() + Duration::from_secs(5);
1242        while process.is_running().unwrap_or(false) {
1243            assert!(
1244                Instant::now() < deadline,
1245                "partially spawned process leaked"
1246            );
1247            thread::sleep(Duration::from_millis(20));
1248        }
1249    }
1250}