Skip to main content

rightkit_process/
owned.rs

1use crate::process_handle::{lock_child, ProcessHandle};
2use std::{
3    ffi::OsStr,
4    io,
5    num::NonZeroU32,
6    process::{ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus},
7    sync::{Arc, Mutex, MutexGuard},
8    thread,
9    time::{Duration, Instant, SystemTime},
10};
11#[cfg(unix)]
12type PlatformChild = std::process::Child;
13#[cfg(windows)]
14type PlatformChild = crate::spawn_windows::WindowsChild;
15
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17pub enum JobAssignmentMode {
18    #[default]
19    Strict,
20    BestEffort,
21}
22
23#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
24pub enum UnixContainment {
25    #[default]
26    ProcessGroup,
27    Session,
28}
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub struct TerminationOptions {
32    /// Send TERM immediately on Unix, then KILL after this tree-wide grace.
33    pub grace: Duration,
34    /// Passed to TerminateJobObject/TerminateProcess on Windows.
35    pub windows_exit_code: u32,
36}
37impl Default for TerminationOptions {
38    fn default() -> Self {
39        Self {
40            grace: Duration::ZERO,
41            windows_exit_code: 1,
42        }
43    }
44}
45impl TerminationOptions {
46    pub fn with_grace(mut self, grace: Duration) -> Self {
47        self.grace = grace;
48        self
49    }
50    pub fn with_windows_exit_code(mut self, code: u32) -> Self {
51        self.windows_exit_code = code;
52        self
53    }
54}
55
56/// Every failed assignment in a best-effort Windows spawn is retained.
57#[derive(Debug)]
58pub struct JobAssignmentFailure {
59    pub stage: &'static str,
60    pub error: io::Error,
61}
62impl JobAssignmentFailure {
63    pub fn raw_os_error(&self) -> Option<i32> {
64        self.error.raw_os_error()
65    }
66}
67
68/// Borrowed OS handle/fd value; keep it valid until spawn returns.
69#[cfg(windows)]
70pub type RawHandleOrFd = std::os::windows::io::RawHandle;
71#[cfg(unix)]
72pub type RawHandleOrFd = std::os::fd::RawFd;
73#[cfg(windows)]
74type InheritedValue = usize;
75#[cfg(unix)]
76type InheritedValue = RawHandleOrFd;
77
78#[derive(Debug)]
79pub struct OwnedCommand {
80    command: Command,
81    windows_hidden: bool,
82    caller_job: CallerJob,
83    job_assignment: JobAssignmentMode,
84    unix_containment: UnixContainment,
85    // None preserves the existing unbounded partial-spawn reap.
86    partial_spawn_cleanup_timeout: Option<Duration>,
87    inherited_handles: Option<Vec<InheritedValue>>,
88    #[cfg(windows)]
89    windows_spawn_config: Option<crate::WindowsSpawnConfig>,
90    #[cfg(windows)]
91    windows_spawn_config_auto: bool,
92    #[cfg(windows)]
93    env_cleared: bool,
94}
95#[cfg(windows)]
96type CallerJob = Option<std::os::windows::io::OwnedHandle>;
97#[cfg(not(windows))]
98type CallerJob = Option<std::convert::Infallible>;
99
100impl OwnedCommand {
101    pub fn new(program: impl AsRef<OsStr>) -> Self {
102        let command = Self::from_command(Command::new(program));
103        #[cfg(windows)]
104        let command = {
105            let mut command = command;
106            // Fresh Commands have known inherited stdio/environment defaults.
107            command.windows_spawn_config = Some(crate::WindowsSpawnConfig::default());
108            command.windows_spawn_config_auto = true;
109            command
110        };
111        command
112    }
113    /// GUI/service children are hidden on Windows by default.
114    pub fn from_command(command: Command) -> Self {
115        Self {
116            command,
117            windows_hidden: true,
118            caller_job: None,
119            job_assignment: JobAssignmentMode::Strict,
120            unix_containment: UnixContainment::ProcessGroup,
121            partial_spawn_cleanup_timeout: None,
122            inherited_handles: None,
123            #[cfg(windows)]
124            windows_spawn_config: None,
125            #[cfg(windows)]
126            windows_spawn_config_auto: false,
127            #[cfg(windows)]
128            env_cleared: false,
129        }
130    }
131    pub fn command_mut(&mut self) -> &mut Command {
132        #[cfg(windows)]
133        if self.windows_spawn_config_auto {
134            // A mutable std Command can change settings with no stable getter.
135            self.windows_spawn_config = None;
136        }
137        &mut self.command
138    }
139    /// Inherit only these handles/fds plus configured stdio; values stay caller-owned.
140    pub fn inherit_handles_only(
141        &mut self,
142        handles: impl IntoIterator<Item = RawHandleOrFd>,
143    ) -> &mut Self {
144        #[cfg(windows)]
145        let handles = handles.into_iter().map(|handle| handle as usize).collect();
146        #[cfg(unix)]
147        let handles = handles.into_iter().collect();
148        self.inherited_handles = Some(handles);
149        self
150    }
151    /// Supply opaque stdio/environment/raw-arg settings for native allowlisted spawns.
152    /// Required after from_command/command_mut when inherit_handles_only is set.
153    /// Ordinary std spawns ignore it; fresh new() Commands have known defaults.
154    #[cfg(windows)]
155    pub fn windows_spawn_config(&mut self, config: crate::WindowsSpawnConfig) -> &mut Self {
156        self.windows_spawn_config = Some(config);
157        self.windows_spawn_config_auto = false;
158        self
159    }
160    /// Clear environment, then copy only named variables set in this process.
161    pub fn env_allowlist<I, S>(&mut self, keys: I) -> &mut Self
162    where
163        I: IntoIterator<Item = S>,
164        S: AsRef<OsStr>,
165    {
166        let kept: Vec<_> = keys
167            .into_iter()
168            .filter_map(|k| std::env::var_os(k.as_ref()).map(|v| (k.as_ref().to_os_string(), v)))
169            .collect();
170        self.command.env_clear();
171        #[cfg(windows)]
172        {
173            self.env_cleared = true;
174        }
175        for (k, v) in kept {
176            self.command.env(k, v);
177        }
178        self
179    }
180    pub fn env_strip<I, S>(&mut self, keys: I) -> &mut Self
181    where
182        I: IntoIterator<Item = S>,
183        S: AsRef<OsStr>,
184    {
185        for k in keys {
186            self.command.env_remove(k);
187        }
188        self
189    }
190    /// Compatibility no-op: hidden is already the default.
191    pub fn windows_hide(&mut self) -> &mut Self {
192        self.windows_hidden = true;
193        self
194    }
195    /// Duplicate caller job; assign before the empty nested RightKit job.
196    #[cfg(windows)]
197    pub fn windows_job(
198        &mut self,
199        job: std::os::windows::io::BorrowedHandle<'_>,
200    ) -> io::Result<&mut Self> {
201        self.caller_job = Some(job.try_clone_to_owned()?);
202        Ok(self)
203    }
204    /// Ignored on Unix. BestEffort reports errors on OwnedChild.
205    pub fn job_assignment_mode(&mut self, mode: JobAssignmentMode) -> &mut Self {
206        self.job_assignment = mode;
207        self
208    }
209    /// Ignored on Windows. Session runs setsid before exec.
210    pub fn unix_containment(&mut self, mode: UnixContainment) -> &mut Self {
211        self.unix_containment = mode;
212        self
213    }
214    /// Bound failed-spawn cleanup. Timed-out children go to a background reaper.
215    pub fn partial_spawn_cleanup_timeout(&mut self, timeout: Duration) -> &mut Self {
216        self.partial_spawn_cleanup_timeout = Some(timeout);
217        self
218    }
219    pub fn spawn(self) -> io::Result<OwnedChild> {
220        self.spawn_inner(false, false, None)
221    }
222    /// Shared launch with no owner job or terminate-on-drop. Unix uses setsid;
223    /// Windows requires breakaway and never falls back when escape is denied.
224    /// Stdio follows Command: dropping piped stdin can still deliver EOF.
225    pub fn spawn_uncontained_detached(self) -> io::Result<OwnedChild> {
226        self.spawn_inner(true, false, None)
227    }
228    fn spawn_inner(
229        mut self,
230        detached: bool,
231        fail_after_spawn: bool,
232        captured_pid: Option<Arc<std::sync::atomic::AtomicU32>>,
233    ) -> io::Result<OwnedChild> {
234        if detached && self.caller_job.is_some() {
235            return Err(io::Error::new(
236                io::ErrorKind::InvalidInput,
237                "detached launch cannot join a caller job",
238            ));
239        }
240        #[cfg(unix)]
241        {
242            use std::os::unix::process::CommandExt;
243            let session = detached || self.unix_containment == UnixContainment::Session;
244            let inheritance = self
245                .inherited_handles
246                .take()
247                .map(crate::process_unix::Inheritance::new)
248                .transpose()?;
249            // Only async-signal-safe libc calls run between fork and exec.
250            unsafe {
251                self.command.pre_exec(move || {
252                    let result = if session {
253                        nix::libc::setsid()
254                    } else {
255                        nix::libc::setpgid(0, 0)
256                    };
257                    if result < 0 {
258                        return Err(io::Error::last_os_error());
259                    }
260                    if let Some(inheritance) = &inheritance {
261                        inheritance.apply()?;
262                    }
263                    Ok(())
264                });
265            }
266        }
267        #[cfg(windows)]
268        crate::process_windows::configure(&mut self.command, detached, self.windows_hidden);
269        #[cfg(unix)]
270        let _ = (self.windows_hidden, self.job_assignment);
271        #[cfg(windows)]
272        let _ = self.unix_containment;
273        let spawned_at = SystemTime::now();
274        #[cfg(unix)]
275        let child = self.command.spawn()?;
276        #[cfg(windows)]
277        let child: PlatformChild = match self.inherited_handles.as_deref() {
278            None => self.command.spawn()?.into(),
279            Some(handles) => {
280                let config = self.windows_spawn_config.take().ok_or_else(|| {
281                    io::Error::new(io::ErrorKind::InvalidInput,
282                        "allowlisted Windows spawn requires windows_spawn_config for opaque Command settings")
283                })?;
284                crate::spawn_windows::spawn(
285                    &self.command,
286                    config,
287                    handles,
288                    detached,
289                    self.windows_hidden,
290                    self.env_cleared,
291                    self.partial_spawn_cleanup_timeout,
292                )?
293            }
294        };
295        let pid = child.id();
296        if let Some(captured) = captured_pid {
297            captured.store(pid, std::sync::atomic::Ordering::SeqCst);
298        }
299        let child = Arc::new(Mutex::new(child));
300        let handle = match ProcessHandle::new(&child, spawned_at) {
301            Ok(handle) => handle,
302            Err(error) => {
303                cleanup_direct(child, self.partial_spawn_cleanup_timeout);
304                return Err(error);
305            }
306        };
307        let mut partial = PartialChild {
308            child: Some(OwnedChild {
309                child,
310                handle,
311                pid,
312                detached,
313                terminate_on_drop: !detached,
314                tree_terminated: false,
315                assignment_failures: Vec::new(),
316                #[cfg(windows)]
317                job: None,
318            }),
319            timeout: self.partial_spawn_cleanup_timeout,
320        };
321        #[cfg(windows)]
322        if !detached {
323            let owned = partial.child.as_mut().expect("partial child is present");
324            crate::process_windows::assign_jobs(
325                &owned.handle,
326                self.caller_job.take(),
327                self.job_assignment,
328                &mut owned.job,
329                &mut owned.assignment_failures,
330            )?;
331        }
332        #[cfg(windows)]
333        partial
334            .child
335            .as_ref()
336            .expect("partial child is present")
337            .child()
338            .resume(detached)?;
339        if fail_after_spawn {
340            return Err(io::Error::other("injected post-spawn wrapping failure"));
341        }
342        Ok(partial.child.take().expect("partial child is present"))
343    }
344}
345
346pub const DEFAULT_ENV_ALLOWLIST: &[&str] = &[
347    "PATH",
348    "HOME",
349    "USER",
350    "LOGNAME",
351    "LANG",
352    "LC_ALL",
353    "TMPDIR",
354    "TEMP",
355    "TMP",
356    "SystemRoot",
357    "SYSTEMROOT",
358    "USERPROFILE",
359    "APPDATA",
360    "LOCALAPPDATA",
361    "ProgramData",
362    "COMSPEC",
363    "PATHEXT",
364    "XDG_DATA_HOME",
365    "XDG_RUNTIME_DIR",
366];
367
368#[derive(Debug)]
369pub struct OwnedChild {
370    child: Arc<Mutex<PlatformChild>>,
371    handle: ProcessHandle,
372    // Captured before any wait can reap the direct child.
373    pid: u32,
374    detached: bool,
375    terminate_on_drop: bool,
376    tree_terminated: bool,
377    assignment_failures: Vec<JobAssignmentFailure>,
378    #[cfg(windows)]
379    job: Option<crate::process_windows::KillOnCloseJob>,
380}
381#[derive(Clone, Copy, Debug, Eq, PartialEq)]
382pub enum ReapOutcome {
383    Exited(ExitStatus),
384    TimedOut,
385}
386#[derive(Clone, Copy, Debug, Eq, PartialEq)]
387pub enum WaitOutcome {
388    Exited(ExitStatus),
389    Terminated(ExitStatus),
390}
391
392impl OwnedChild {
393    pub fn id(&self) -> u32 {
394        self.pid
395    }
396    /// Borrow child state, including stdio, wait and try_wait. Windows uses a
397    /// native view with std pipe types for both spawn paths. Release this guard
398    /// before another accessor or exit reader; it holds the shared reap lock.
399    pub fn child(&self) -> MutexGuard<'_, PlatformChild> {
400        lock_child(&self.child)
401    }
402    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
403        self.child().stdin.take()
404    }
405    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
406        self.child().stdout.take()
407    }
408    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
409        self.child().stderr.take()
410    }
411    pub fn job_assignment_failures(&self) -> &[JobAssignmentFailure] {
412        &self.assignment_failures
413    }
414    pub fn process_handle(&self) -> io::Result<ProcessHandle> {
415        self.handle.try_clone()
416    }
417    pub fn creation_time(&self) -> Option<SystemTime> {
418        self.handle.creation_time()
419    }
420    #[cfg(windows)]
421    pub fn creation_time_ticks(&self) -> Option<u64> {
422        self.handle.creation_time_ticks()
423    }
424    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
425        self.child().try_wait()
426    }
427    // Preserve std Child::wait's stdin-close behavior. ProcessHandle performs
428    // native blocking waits & keeps Unix reap results in std's shared cache.
429    pub fn wait(&mut self) -> io::Result<ExitStatus> {
430        drop(self.child().stdin.take());
431        self.handle.wait()
432    }
433    pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result<Option<ExitStatus>> {
434        self.handle.wait_timeout(timeout)
435    }
436    pub fn wait_or_kill(&mut self, grace: Duration) -> io::Result<WaitOutcome> {
437        #[cfg(unix)]
438        if !self.tree_terminated {
439            self.signal(nix::libc::SIGTERM)?;
440        }
441        if let Some(status) = self.wait_timeout(grace)? {
442            return Ok(WaitOutcome::Exited(status));
443        }
444        self.terminate_tree().map(WaitOutcome::Terminated)
445    }
446    /// Immediate forced termination, preserving the existing default.
447    /// Retained group/job is signalled even after the parent was reaped.
448    pub fn terminate_tree(&mut self) -> io::Result<ExitStatus> {
449        self.force_kill(1)?;
450        self.wait()
451    }
452    /// Unix: TERM now, tree-wide grace, then KILL even if parent exited.
453    /// Windows: immediately terminate retained job with supplied exit code.
454    pub fn terminate_tree_with_options(
455        &mut self,
456        options: TerminationOptions,
457    ) -> io::Result<ExitStatus> {
458        self.start_termination(options)?;
459        self.wait()
460    }
461    pub fn terminate_tree_bounded(&mut self, timeout: Duration) -> io::Result<ReapOutcome> {
462        self.terminate_tree_bounded_with_options(timeout, TerminationOptions::default())
463    }
464    /// timeout covers grace plus reap; grace is clipped to timeout.
465    pub fn terminate_tree_bounded_with_options(
466        &mut self,
467        timeout: Duration,
468        mut options: TerminationOptions,
469    ) -> io::Result<ReapOutcome> {
470        let started = Instant::now();
471        options.grace = options.grace.min(timeout);
472        self.start_termination(options)?;
473        Ok(
474            match self.wait_timeout(timeout.saturating_sub(started.elapsed()))? {
475                Some(status) => ReapOutcome::Exited(status),
476                None => ReapOutcome::TimedOut,
477            },
478        )
479    }
480    fn start_termination(&mut self, options: TerminationOptions) -> io::Result<()> {
481        #[cfg(unix)]
482        if !self.tree_terminated {
483            self.signal(nix::libc::SIGTERM)?;
484            let started = Instant::now();
485            while started.elapsed() < options.grace {
486                // Reap an exited leader promptly; descendants still keep the
487                // captured group alive, so their grace is never shortened.
488                let _ = self.handle.try_wait()?;
489                if !self.tree_exists()? {
490                    break;
491                }
492                thread::sleep(
493                    Duration::from_millis(5).min(options.grace.saturating_sub(started.elapsed())),
494                );
495            }
496        }
497        self.force_kill(options.windows_exit_code)
498    }
499    #[cfg(unix)]
500    fn signal(&self, signal: i32) -> io::Result<()> {
501        // Detached children only own their direct pid. Never signal a reused
502        // direct pid once std has observed exit.
503        if self.detached && self.handle.try_wait()?.is_some() {
504            return Ok(());
505        }
506        let target = if self.detached {
507            self.pid as i32
508        } else {
509            -(self.pid as i32)
510        };
511        if unsafe { nix::libc::kill(target, signal) } == 0 {
512            return Ok(());
513        }
514        let error = io::Error::last_os_error();
515        if error.raw_os_error() == Some(nix::libc::ESRCH) || self.group_eperm_after_exit(&error)? {
516            Ok(())
517        } else {
518            Err(error)
519        }
520    }
521    #[cfg(unix)]
522    fn tree_exists(&self) -> io::Result<bool> {
523        if self.detached && self.handle.try_wait()?.is_some() {
524            return Ok(false);
525        }
526        let target = if self.detached {
527            self.pid as i32
528        } else {
529            -(self.pid as i32)
530        };
531        if unsafe { nix::libc::kill(target, 0) } == 0 {
532            return Ok(true);
533        }
534        let error = io::Error::last_os_error();
535        if error.raw_os_error() == Some(nix::libc::ESRCH) || self.group_eperm_after_exit(&error)? {
536            Ok(false)
537        } else {
538            Err(error)
539        }
540    }
541    /// macOS answers `kill(-pgid, ..)` with EPERM (not ESRCH) when the group's
542    /// remaining members are zombies; once the leader has exited, treat that as gone.
543    #[cfg(unix)]
544    fn group_eperm_after_exit(&self, error: &io::Error) -> io::Result<bool> {
545        Ok(!self.detached
546            && error.raw_os_error() == Some(nix::libc::EPERM)
547            && self.handle.try_wait()?.is_some())
548    }
549    fn force_kill(&mut self, code: u32) -> io::Result<()> {
550        if self.tree_terminated {
551            return Ok(());
552        }
553        #[cfg(unix)]
554        {
555            let _ = code;
556            self.signal(nix::libc::SIGKILL)?;
557        }
558        #[cfg(windows)]
559        crate::process_windows::terminate(&self.handle, self.job.as_ref(), code)?;
560        self.tree_terminated = true;
561        Ok(())
562    }
563}
564impl Drop for OwnedChild {
565    fn drop(&mut self) {
566        if self.terminate_on_drop {
567            let _ = self.terminate_tree();
568        } else if self.detached {
569            #[cfg(unix)]
570            {
571                // Shared launch still needs eventual reaping. Close owner pipes
572                // now; poll cached std state without blocking exit-reader locks.
573                {
574                    let mut child = self.child();
575                    drop(child.stdin.take());
576                    drop(child.stdout.take());
577                    drop(child.stderr.take());
578                }
579                if let Ok(reader) = self.handle.try_clone() {
580                    let _ = thread::Builder::new()
581                        .name("rightkit-detached-reap".into())
582                        .spawn(move || {
583                            let _ = reader.wait();
584                        });
585                }
586            }
587        }
588    }
589}
590
591struct PartialChild {
592    child: Option<OwnedChild>,
593    timeout: Option<Duration>,
594}
595impl Drop for PartialChild {
596    fn drop(&mut self) {
597        if let Some(mut child) = self.child.take() {
598            // Disable unbounded normal Drop before bounded cleanup.
599            child.terminate_on_drop = false;
600            let _ = child.force_kill(1);
601            cleanup_direct(Arc::clone(&child.child), self.timeout);
602        }
603    }
604}
605pub(crate) fn cleanup_direct(child: Arc<Mutex<PlatformChild>>, timeout: Option<Duration>) {
606    let _ = lock_child(&child).kill();
607    let Some(timeout) = timeout else {
608        let _ = lock_child(&child).wait();
609        return;
610    };
611    let started = Instant::now();
612    loop {
613        match lock_child(&child).try_wait() {
614            Ok(Some(_)) => return,
615            Err(_) => break,
616            Ok(None) => {}
617        }
618        if started.elapsed() >= timeout {
619            break;
620        }
621        thread::sleep(Duration::from_millis(5).min(timeout.saturating_sub(started.elapsed())));
622    }
623    // std Child does not reap on Drop. Transfer timed-out children to a waiter.
624    let _ = thread::Builder::new()
625        .name("rightkit-partial-reap".into())
626        .spawn(move || {
627            let _ = lock_child(&child).wait();
628        });
629}
630
631#[derive(Clone, Copy, Debug, Eq, PartialEq)]
632pub struct AdoptedProcess {
633    pid: NonZeroU32,
634}
635impl AdoptedProcess {
636    pub fn new(pid: NonZeroU32) -> Self {
637        Self { pid }
638    }
639    pub fn id(&self) -> u32 {
640        self.pid.get()
641    }
642    pub fn is_running(&self) -> io::Result<bool> {
643        process_is_running(self.pid)
644    }
645}
646#[cfg(test)]
647fn spawn_for_test(
648    command: Command,
649    captured_pid: Arc<std::sync::atomic::AtomicU32>,
650) -> io::Result<OwnedChild> {
651    OwnedCommand::from_command(command).spawn_inner(false, true, Some(captured_pid))
652}
653
654#[cfg(windows)]
655fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
656    use windows::Win32::{
657        Foundation::{CloseHandle, ERROR_INVALID_PARAMETER, WAIT_TIMEOUT},
658        System::Threading::{
659            OpenProcess, WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION,
660            PROCESS_SYNCHRONIZE,
661        },
662    };
663
664    let handle = unsafe {
665        OpenProcess(
666            PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
667            false,
668            pid.get(),
669        )
670    };
671    let handle = match handle {
672        Ok(handle) => handle,
673        Err(error) => {
674            if error.code() == ERROR_INVALID_PARAMETER.to_hresult() {
675                return Ok(false);
676            }
677            return Err(io::Error::other(error.to_string()));
678        }
679    };
680    let wait = unsafe { WaitForSingleObject(handle, 0) };
681    let close = unsafe { CloseHandle(handle) };
682    close.map_err(|error| io::Error::other(error.to_string()))?;
683    match wait.0 {
684        0 => Ok(false),
685        value if value == WAIT_TIMEOUT.0 => Ok(true),
686        _ => Err(io::Error::last_os_error()),
687    }
688}
689
690#[cfg(unix)]
691fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
692    use nix::{errno::Errno, sys::signal::kill, unistd::Pid};
693
694    let pid = i32::try_from(pid.get())
695        .map(Pid::from_raw)
696        .map_err(io::Error::other)?;
697    match kill(pid, None) {
698        Ok(()) | Err(Errno::EPERM) => Ok(true),
699        Err(Errno::ESRCH) => Ok(false),
700        Err(error) => Err(io::Error::from(error)),
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    #[test]
707    fn option_defaults_preserve_existing_behavior() {
708        let command = super::OwnedCommand::new("unused");
709        assert_eq!(command.job_assignment, super::JobAssignmentMode::Strict);
710        assert_eq!(
711            command.unix_containment,
712            super::UnixContainment::ProcessGroup
713        );
714        assert_eq!(command.partial_spawn_cleanup_timeout, None);
715        assert_eq!(
716            super::TerminationOptions::default().grace,
717            std::time::Duration::ZERO
718        );
719        assert_eq!(super::TerminationOptions::default().windows_exit_code, 1);
720    }
721
722    #[test]
723    fn option_builders_keep_every_requested_value() {
724        use super::{JobAssignmentMode, OwnedCommand, TerminationOptions, UnixContainment};
725        use std::time::Duration;
726        let mut command = OwnedCommand::new("unused");
727        command
728            .job_assignment_mode(JobAssignmentMode::BestEffort)
729            .unix_containment(UnixContainment::Session)
730            .partial_spawn_cleanup_timeout(Duration::from_secs(5));
731        assert_eq!(command.job_assignment, JobAssignmentMode::BestEffort);
732        assert_eq!(command.unix_containment, UnixContainment::Session);
733        assert_eq!(
734            command.partial_spawn_cleanup_timeout,
735            Some(Duration::from_secs(5))
736        );
737        let options = TerminationOptions::default()
738            .with_grace(Duration::from_millis(100))
739            .with_windows_exit_code(1067);
740        assert_eq!(options.grace, Duration::from_millis(100));
741        assert_eq!(options.windows_exit_code, 1067);
742    }
743
744    #[test]
745    fn zero_bound_partial_spawn_failure_still_reaps_the_child() {
746        use std::{
747            num::NonZeroU32,
748            process::Command,
749            sync::{
750                atomic::{AtomicU32, Ordering},
751                Arc,
752            },
753            thread,
754            time::{Duration, Instant},
755        };
756        let command = if cfg!(windows) {
757            let mut command = Command::new("powershell.exe");
758            command.args([
759                "-NoLogo",
760                "-NoProfile",
761                "-NonInteractive",
762                "-Command",
763                "Start-Sleep -Seconds 30",
764            ]);
765            command
766        } else {
767            let mut command = Command::new("/bin/sh");
768            command.args(["-c", "exec sleep 30"]);
769            command
770        };
771        let mut command = super::OwnedCommand::from_command(command);
772        command.partial_spawn_cleanup_timeout(Duration::ZERO);
773        let captured = Arc::new(AtomicU32::new(0));
774        let started = Instant::now();
775        assert!(command
776            .spawn_inner(false, true, Some(Arc::clone(&captured)))
777            .is_err());
778        assert!(started.elapsed() < Duration::from_secs(3));
779        let pid = captured.load(Ordering::SeqCst);
780        assert_ne!(pid, 0);
781        let process = super::AdoptedProcess::new(NonZeroU32::new(pid).unwrap());
782        let started = Instant::now();
783        while process.is_running().unwrap() {
784            assert!(started.elapsed() < Duration::from_secs(5));
785            thread::sleep(Duration::from_millis(5));
786        }
787    }
788
789    #[test]
790    fn owned_commands_hide_their_console_window_by_default() {
791        assert!(super::OwnedCommand::new("x").windows_hidden);
792        assert!(super::OwnedCommand::from_command(std::process::Command::new("x")).windows_hidden);
793    }
794
795    /// A kill-on-close job standing in for an in-process host's own job object.
796    #[cfg(windows)]
797    fn host_job() -> std::os::windows::io::OwnedHandle {
798        use std::os::windows::io::FromRawHandle;
799        use windows::Win32::System::JobObjects::{
800            CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
801            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
802        };
803        let job = unsafe { CreateJobObjectW(None, None) }.unwrap();
804        let job_handle = unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(job.0 as _) };
805        let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
806        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
807        unsafe {
808            SetInformationJobObject(
809                job,
810                JobObjectExtendedLimitInformation,
811                &info as *const _ as *const _,
812                std::mem::size_of_val(&info) as u32,
813            )
814        }
815        .unwrap();
816        job_handle
817    }
818
819    #[cfg(windows)]
820    fn in_job(pid: u32, job: &std::os::windows::io::OwnedHandle) -> bool {
821        use std::os::windows::io::AsRawHandle;
822        #[link(name = "kernel32")]
823        unsafe extern "system" {
824            fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut std::ffi::c_void;
825            fn IsProcessInJob(
826                process: *mut std::ffi::c_void,
827                job: *mut std::ffi::c_void,
828                result: *mut i32,
829            ) -> i32;
830            fn CloseHandle(handle: *mut std::ffi::c_void) -> i32;
831        }
832        unsafe {
833            let process = OpenProcess(0x1000, 0, pid); // PROCESS_QUERY_LIMITED_INFORMATION
834            assert!(!process.is_null());
835            let mut inside = 0;
836            assert_ne!(
837                IsProcessInJob(process, job.as_raw_handle() as _, &mut inside),
838                0
839            );
840            CloseHandle(process);
841            inside != 0
842        }
843    }
844
845    #[cfg(windows)]
846    #[test]
847    fn children_join_the_callers_job_and_die_when_the_caller_closes_it() {
848        use std::os::windows::io::AsHandle;
849        use std::time::{Duration, Instant};
850        let job = host_job();
851        let sleeper = || {
852            let mut command = super::OwnedCommand::new("powershell.exe");
853            command.command_mut().args([
854                "-NoLogo",
855                "-NoProfile",
856                "-NonInteractive",
857                "-Command",
858                "Start-Sleep -Seconds 60",
859            ]);
860            command.windows_job(job.as_handle()).unwrap();
861            command.spawn().unwrap()
862        };
863        // The second child joins a job that already holds a process: binding must happen
864        // before RightKit's own jobs, or Windows refuses it.
865        let (first, second) = (sleeper(), sleeper());
866        let pids = [first.id(), second.id()];
867        assert!(
868            pids.iter().all(|pid| in_job(*pid, &job)),
869            "children must be in the caller's job"
870        );
871
872        // The caller closes its job while the owned children are still alive: they die.
873        drop(job);
874        let deadline = Instant::now() + Duration::from_secs(5);
875        for pid in pids {
876            let process = super::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
877            while process.is_running().unwrap_or(false) {
878                assert!(
879                    Instant::now() < deadline,
880                    "child {pid} survived its caller's job"
881                );
882                std::thread::sleep(Duration::from_millis(20));
883            }
884        }
885        drop((first, second));
886    }
887
888    #[test]
889    fn a_failure_after_spawn_cleans_up_the_partially_wrapped_child() {
890        use std::{
891            process::Command,
892            sync::{
893                atomic::{AtomicU32, Ordering},
894                Arc,
895            },
896            thread,
897            time::{Duration, Instant},
898        };
899
900        // A long-lived child that survives on its own if the partial-spawn cleanup fails.
901        let command = if cfg!(windows) {
902            // windowless: spawned through spawn_owned (CREATE_NO_WINDOW).
903            let mut command = Command::new("powershell.exe");
904            command.args([
905                "-NoLogo",
906                "-NoProfile",
907                "-NonInteractive",
908                "-Command",
909                "Start-Sleep -Seconds 60",
910            ]);
911            command
912        } else {
913            // windowless: spawned through spawn_owned (CREATE_NO_WINDOW).
914            let mut command = Command::new("sleep");
915            command.arg("60");
916            command
917        };
918        let spawned_pid = Arc::new(AtomicU32::new(0));
919
920        assert!(super::spawn_for_test(command, Arc::clone(&spawned_pid)).is_err());
921        let pid = spawned_pid.load(Ordering::SeqCst);
922        assert_ne!(pid, 0, "test must fail after the OS process was spawned");
923        let process = super::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
924        let deadline = Instant::now() + Duration::from_secs(5);
925        while process.is_running().unwrap_or(false) {
926            assert!(
927                Instant::now() < deadline,
928                "partially spawned process leaked"
929            );
930            thread::sleep(Duration::from_millis(20));
931        }
932    }
933}