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